diff --git a/.gitignore b/.gitignore index 8d70d30..f0815b9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ bin-go/ .seo/blog-drafts/ .seo/skills/ .seo/patches/ +# Fixed mise release archives fetched by task sync-mise, embedded per target. +/packages/cli/internal/adapters/runtime/mise/assets/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 478f074..130fdd3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,6 +15,10 @@ version: 2 project_name: one-cli +before: + hooks: + - go run ./packages/cli/tools/sync-mise --all + builds: - id: one dir: packages/cli @@ -53,6 +57,7 @@ archives: name_template: "one-cli_{{ .Os }}_{{ .Arch }}" files: - README* + - third_party/mise/LICENSE checksum: name_template: "checksums.txt" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 112a219..779fcdb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,8 @@ one --version # 验证装好 > **fresh-clone 提示**:`packages/cli/internal/resources/bundled/` 整个目录是 gitignore 的—— > registry / templates / dashboard dist 都由 `task sync-bundled` + -> `task sync-web` 按需重建,作为 `task vet` / `test` / `build` 的依赖自动跑。 +> `task sync-web` 按需重建。`sync-bundled` 还会运行 `sync-mise`,下载并校验 +> 当前平台的固定 mise 压缩包用于内置,作为 `task vet` / `test` / `build` 的依赖自动跑。 > 第一次 `task install` 会触发 `pnpm install + vite build`,~30s;之后 > task fingerprint 命中,几乎零成本。如果你直接跑 `go build` 而不走 Taskfile, > 会看到 `pattern all:_templates: no matching files found` 这种报错——跑一次 @@ -68,6 +69,18 @@ PR CI 会并行执行 `task check:static` 与 `task check:test`,两者合起 - 公开 API(`packages/cli/pkg/`)改动要考虑 semver;详见 [CLAUDE.md 的 Public API stability](./CLAUDE.md) - 加新错误码:在 `packages/cli/internal/platform/errors/codes.go` 注册 `Code` 常量 + `Codes` map 条目;测试会强制对应;改完跑 `task gen-error-codes` 刷新文档 +### 内置 mise + +发布的 One 文件内置对应平台的 mise 压缩包,首次运行从自身解压,不下载 mise。 +`task sync-mise` 在构建时下载并校验本机平台资源;`task sync-mise-all` 准备五个平台。 +`task build`、检查任务自动准备本机资源,`task build-all` 和 GoReleaser 自动准备全部平台。 +资源位于被忽略的 `packages/cli/internal/adapters/runtime/mise/assets/`,不提交二进制资源。 +资源已准备且摘要匹配时可离线构建。首次构建需要访问 GitHub Releases。 + +升级时更新 `internal/adapters/runtime/mise/miserelease/release.go` 中的版本、压缩包和解压后程序的 SHA256, +以及 runtime 最低版本和相关文档;重新执行 `task sync-mise-all`。上游许可证保留于 +`third_party/mise/LICENSE`,也包含在内置的原始压缩包和 One 发布归档中。 + ### 改 templates(`packages/templates//`) - 模板会被 `go:embed` 进二进制(`task sync-bundled` 是同步入口,自动跑) diff --git a/Taskfile.yml b/Taskfile.yml index a3cc0ad..0489b94 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -31,6 +31,8 @@ env: GOPROXY: '{{.GOPROXY}}' tasks: + # Keep cached tasks at the repository root so sources/generates resolve + # against the same paths. Use go -C when a command needs the CLI directory. default: desc: List available tasks cmds: @@ -39,30 +41,35 @@ tasks: build: desc: Build the local binary into packages/cli/bin/one deps: [sync-bundled, sync-web] - dir: '{{.CLI_DIR}}' status: - - test -f 'bin/one{{.GOEXE}}' - - test "$(bin/one{{.GOEXE}} --version)" = "{{.BUILD_VERSION}}" + - test -f '{{.BIN}}' + - test "$({{.BIN}} --version)" = "{{.BUILD_VERSION}}" cmds: - - mkdir -p bin - - go build -trimpath -ldflags '{{.LDFLAGS}}' -o 'bin/one{{.GOEXE}}' ./cmd/one + - mkdir -p '{{.BIN_DIR}}' + - go -C '{{.CLI_DIR}}' build -trimpath -ldflags '{{.LDFLAGS}}' -o 'bin/one{{.GOEXE}}' ./cmd/one sources: + - 'Taskfile.yml' - 'go.work' - 'go.work.sum' - 'packages/kernel/go.mod' - 'packages/kernel/**/*.go' - 'packages/cli/cmd/**/*.go' - 'packages/cli/internal/**/*.go' + - 'packages/cli/internal/adapters/runtime/mise/assets/*.tar.gz' + - 'packages/cli/internal/adapters/runtime/mise/assets/*.zip' + - 'packages/cli/internal/adapters/deploy/kustomize/templates/*' + - 'packages/cli/internal/platform/i18n/locales/*.json' + - 'packages/cli/internal/resources/bundled/**/*' - 'packages/cli/pkg/**/*.go' - 'packages/cli/go.mod' + - 'packages/cli/go.sum' generates: - '{{.BIN}}' gen-error-codes: desc: Render apps/docs/content/docs/zh/error-codes.md from internal/platform/errors/codes.go - dir: '{{.CLI_DIR}}' cmds: - - go run ./tools/gen-error-codes + - go -C '{{.CLI_DIR}}' run ./tools/gen-error-codes sources: - 'packages/cli/internal/platform/errors/codes.go' - 'packages/cli/tools/gen-error-codes/**/*.go' @@ -76,9 +83,8 @@ tasks: # registry/_templates; sync-web covers _web. Both run before # `go run` here so a fresh checkout works without prior task invocations. deps: [sync-bundled, sync-web] - dir: '{{.CLI_DIR}}' cmds: - - go run ./tools/verify-cli-references + - go -C '{{.CLI_DIR}}' run ./tools/verify-cli-references sources: - 'packages/cli/internal/**/*.go' - 'packages/cli/tools/verify-cli-references/**/*.go' @@ -94,9 +100,8 @@ tasks: # The test package imports internal/core/template → internal/resources/bundled, so it # needs the embed sources present. Mirrors verify-cli-references. deps: [sync-bundled, sync-web] - dir: '{{.CLI_DIR}}' cmds: - - go test -count=1 -run 'TestTemplateCodesMatchGoldenAndRegistry|TestDeployCodesMatchGolden|TestEnvCodesMatchGolden|TestContainerCodesMatchGolden|TestGoldenSortedByCode' ./internal/modules/preset/... + - go -C '{{.CLI_DIR}}' test -count=1 -run 'TestTemplateCodesMatchGoldenAndRegistry|TestDeployCodesMatchGolden|TestEnvCodesMatchGolden|TestContainerCodesMatchGolden|TestGoldenSortedByCode' ./internal/modules/preset/... sources: - 'packages/cli/internal/modules/preset/**/*.go' - 'packages/cli/testdata/preset/v1_codes.json' @@ -105,9 +110,8 @@ tasks: verify-preset-vectors: desc: Lock the v1 preset id encoding — Encode(spec) and Parse(id) roundtrip every frozen vector deps: [sync-bundled, sync-web] - dir: '{{.CLI_DIR}}' cmds: - - go test -count=1 -run 'TestEncodeVectors|TestParseEncodeRoundTrip|TestEncodeOrderIndependent' ./internal/modules/preset/... + - go -C '{{.CLI_DIR}}' test -count=1 -run 'TestEncodeVectors|TestParseEncodeRoundTrip|TestEncodeOrderIndependent' ./internal/modules/preset/... sources: - 'packages/cli/internal/modules/preset/**/*.go' - 'packages/cli/testdata/preset/v1_vectors.json' @@ -119,11 +123,11 @@ tasks: # registry/_templates; sync-web covers _web. Mirrors the deps # on verify-cli-references and verify-preset-codes. deps: [sync-bundled, sync-web] - dir: '{{.CLI_DIR}}' cmds: - - go run ./tools/verify-help + - go -C '{{.CLI_DIR}}' run ./tools/verify-help sources: - 'packages/cli/internal/**/*.go' + - 'packages/cli/internal/platform/i18n/locales/*.json' - 'packages/cli/tools/verify-help/**/*.go' verify-docs: @@ -183,7 +187,7 @@ tasks: build-all: desc: Cross-compile every supported platform under go-dist/ - deps: [sync-bundled] + deps: [sync-bundled, sync-mise-all, sync-web] dir: '{{.CLI_DIR}}' cmds: - rm -rf ../../go-dist @@ -361,7 +365,20 @@ tasks: generates: - 'apps/dashboard/dist/index.html' + sync-mise: + desc: Fetch and verify the pinned mise archive embedded for the local platform + run: once + cmds: + - go run ./packages/cli/tools/sync-mise + + sync-mise-all: + desc: Fetch and verify mise archives for all release platforms + run: once + cmds: + - go run ./packages/cli/tools/sync-mise --all + sync-bundled: + deps: [sync-mise] desc: Regenerate registry/_templates under packages/cli/internal/resources/bundled (go:embed sources) run: once summary: | diff --git a/apps/docs/content/docs/zh/add.md b/apps/docs/content/docs/zh/add.md index f029514..c6adaa2 100644 --- a/apps/docs/content/docs/zh/add.md +++ b/apps/docs/content/docs/zh/add.md @@ -3,6 +3,8 @@ title: one add description: 往工作区里加一个模板化项目。 --- +工作区已启用 hk 时,`one add` 会同步更新语言检查:Go 加入格式检查,JS/TS 根据项目工具加入 lint 和格式检查。用户的 `hk.pkl` 保留不变。旧工作区可先通过 `one configure hooks` 启用,详见 [`one hk`](/zh/docs/hk/)。 + `one add` 选择技术栈,生成一个可本地开发的项目并登记到 manifest。CI 和部署默认都保持未配置。 有两条入口: @@ -28,7 +30,7 @@ one add [template-id] --name [--deploy-provider ] [optio | `--deploy-provider ` | 显式选择 deploy 后端(必须在模板的 compat 列表里) | | `-o, --output ` | `json` / `yaml` / `text` | -工作区根用 pnpm;项目自身的工具链由模板决定(Node 模板用 pnpm,Go 模板用 Go toolchain,等等)。 +首次添加 JS/TS 项目时初始化 Node monorepo,新工作区默认使用 pnpm;已有 Node 工作区沿用其包管理器。首次添加 Go 模块时初始化根 `go.work`,从第一个模块开始维护 `use` 成员。Go 与 Node 配置可以共存,后续添加只增量登记。已有 `go.work` 的注释、`replace`、`toolchain` 和外部成员会保留;配置冲突会在写入前报告。 ## 交互模式 @@ -127,4 +129,4 @@ one add nestjs-api --name user-api --yes -o json | jq - Agent 文档和本地开发配置会由 `one add` 同步 - 下一步运行 `one dev `;需要部署时再运行 `one deploy ` - 如需持续集成,单独运行 `one ci enable ` 生成 GitHub Actions 工作流 -- `one add` 不自动安装依赖:JS / TS 工作区在根目录跑 package manager install;Go 项目进项目目录跑 `go mod download`,修改 imports 或需要修复模块元数据时再跑 `go mod tidy` +- `one add` 只生成项目和工作区配置;`one dev` 会自动准备工具与应用依赖。JS/TS 在根目录统一安装,Go 按当前模块或 `go.work` 构建图准备依赖。修改 imports 或模块声明需要修复时,显式运行 `one run -- go mod tidy`。 diff --git a/apps/docs/content/docs/zh/cli-overview.md b/apps/docs/content/docs/zh/cli-overview.md index 140b6a7..4742abe 100644 --- a/apps/docs/content/docs/zh/cli-overview.md +++ b/apps/docs/content/docs/zh/cli-overview.md @@ -22,6 +22,7 @@ description: one 顶层命令、常用子命令、输出模式和 agent 自动 | `one deploy` | 按 project 派发 kustomize / S3-compatible / Vercel / Cloudflare / EdgeOne 部署 | `one deploy --dry-run` | | `one ci` | 查看或管理可选的持续集成 | `one ci` | | `one run` | 注入项目 `.env` 后执行任意命令 | `one run -- npm test` | +| `one hk` | 工作区检查、显式修复和 Git hooks | `one hk check --all` | | `one configure` | 配置机器级 endpoint profile | `one configure` | | `one serve` | 启动本地 Workspace、Project 与 Profile Dashboard | `one serve` | @@ -73,6 +74,8 @@ one env pull [--env ] [-p ] [--force] [--dry-run] ## 本机连接 +工作区的检查与提交 hook 使用 `one configure hooks` 配置,详见 [`one hk`](/zh/docs/hk/)。下面的连接命令用于机器级服务配置。 + ```bash one configure one configure add diff --git a/apps/docs/content/docs/zh/configure.md b/apps/docs/content/docs/zh/configure.md index d40f422..a08332c 100644 --- a/apps/docs/content/docs/zh/configure.md +++ b/apps/docs/content/docs/zh/configure.md @@ -3,7 +3,7 @@ title: one configure description: 管理部署、环境变量和镜像仓库所需的本机连接与偏好设置。 --- -`one configure` 管理**本机连接和偏好设置**,不是工作区业务配置。密钥只保存在本机,不写入工作区或 Git。 +`one configure` 管理**本机连接和偏好设置**;`one configure mise` 生成工作区工具配置,`one configure hooks` 生成 hk 检查并安装本地 Git 启动器。连接密钥只保存在本机,不写入工作区或 Git。 ## 用法 @@ -18,10 +18,16 @@ one configure use --profile one configure remove --profile one configure locale [auto|zh-CN|en-US] one configure open +one configure mise [--dry-run] [--node-version ] [--go-version ] +one configure hooks [--dry-run] ``` 没有连接时,无参 `one configure` 进入建立连接向导;已有连接时显示简洁概览。`show` / `use` / `remove` 在终端可直接选择已有连接;脚本仍显式传 `` 和 `--profile`。 +## hooks 工作区提交检查 + +新工作区已经配置 hk。克隆后运行 `one configure hooks` 安装当前 checkout 的钩子;旧工作区可先用 `one configure hooks --dry-run -o json` 预览迁移。配置过程不下载工具,保留用户自定义检查;具体规则和迁移限制见 [`one hk`](/zh/docs/hk/)。 + ## 交互模式 本地人工配置推荐用交互式向导: @@ -112,6 +118,37 @@ one configure add container/ghcr --profile ghcr \ 三个 JSON 文件都是 mode `0600` 的机器本地文件;`profile-bindings.json` 只含名字。它们都不会修改或升级 `one.manifest.json`。`show` 默认掩码敏感字段,只有 `show --reveal` 会输出明文。 +## mise 工作区工具配置 + +新建 workspace 会自动生成 mise 配置,添加项目时自动更新。日常仍使用原来的命令: + +```bash +one create my-app -y +cd my-app +one add react-spa --name web -y +one dev web +one run web -- pnpm build +``` + +旧 workspace 可一次性生成配置,之后也使用同样的日常命令: + +```bash +one configure mise --dry-run -o json +one configure mise +``` + +预览返回每个文件的 `before` / `after`,不执行 mise、不联网、不读取项目密钥。实际写入仅涉及根目录和各项目的 `.mise/conf.d/one.toml`,这些生成文件可纳入 Git。Manifest schema 保持 v1。 + +根配置固定 Node 版本(默认 `24.15.0`,再次生成沿用之前的版本),包管理器版本来自根 `package.json#packageManager`;Go 项目使用 `go.mod` 的 `go` / `toolchain` 声明。需要调整时用 `--node-version` / `--go-version` 指定完整版本。Go override 不能低于 `go.mod` 最低要求;自定义 Node engines 的兼容性需自行确认,当前尚未解析完整 npm 版本范围。 + +One 保留用户的 `mise.toml` 和自定义任务;同目录的 `mise.toml` 可以覆盖生成默认值。手改生成文件会触发 `MISE_CONFIG_CONFLICT`,应将定制内容移入用户配置,再恢复生成文件。已有冲突会在 `one add` 渲染项目之前报告;若后续磁盘写入失败,项目保留,修复错误后运行 `one configure mise` 完成配置。 + +根据项目已有能力生成 `one:dev`、`one:build`、`one:test`、`one:lint`。这些任务执行时读取当前 Manifest、package scripts 或 Taskfile;额外命令参数继续通过 `one run -- [args...]` 传递。按需使用 `one mise` 访问配置信任、诊断和任务命令,无需单独安装 mise。手动运行 mise 任务时,PATH 中的 One 必须支持生成配置的执行协议;也可设置 `ONE_BINARY_PATH` 为测试版 One 的绝对路径。 + +`ONE_RUNTIME=builtin` 可用于临时诊断,让 `one run` / `one dev` 使用机器现有工具;该模式不提供 mise 环境。移除该变量即可恢复自动选择。没有根生成配置的旧 workspace 默认使用 builtin,不会静默迁移。 + +此轮仅接入 `run`、`dev` 和创建流程。CI、部署前构建、工具锁文件生成和跨平台工具安装矩阵留待下一阶段;它们目前仍沿用原有实现。工具的精确版本声明不等于完整的跨平台 `mise.lock`。 + ## 输出 schema | 命令 | schema | @@ -124,6 +161,7 @@ one configure add container/ghcr --profile ghcr \ | `show` | `one-cli/configure-show/v1` | | `use` | `one-cli/configure-use/v1` | | `remove` | `one-cli/configure-remove/v1` | +| `mise` | `one-cli/mise-config/v1` | ## 错误恢复 diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md index d3adc13..6c9b32a 100644 --- a/apps/docs/content/docs/zh/create.md +++ b/apps/docs/content/docs/zh/create.md @@ -47,6 +47,14 @@ one create my-app --yes --env-provider infisical |---|---|---| | 环境变量 | 本地 `.env` 文件 | 可通过 `--env-provider infisical` 或后续 `one env switch infisical` 切换到 Infisical | | 本地开发 | `one dev` | 通过内置进程管理器运行各项目的开发命令 | +| 工具环境 | mise | 自动生成根 `.mise/conf.d/one.toml`;后续 `one add` 自动生成项目配置 | +| Git 检查 | hk | 创建共享检查配置并安装本地提交钩子;后续 `one add` 增量加入语言检查 | + +创建和添加项目只生成配置,不下载工具。首次运行时 One 从自身解压内置 mise,用户无需单独安装或下载 mise;正常命令保持不变。工具版本与已有 workspace 的启用方式见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 + +空工作区先保持语言无关:首次添加 Go 模块时创建根 `go.work` 并登记该模块;首次添加 JS/TS 项目时创建根 `package.json` 和 `pnpm-workspace.yaml`。后续项目增量加入,两套配置可以共存。Git hooks 从创建工作区时就由 hk 提供,纯 Go 工作区不生成 Node 配置;JS 工作区也不再依赖 Husky 或 commitlint。工作区不默认安装版本管理工具或生成 Changesets 配置,发布流程由项目按需配置。 + +提交前默认只检查暂存内容,使用 `one hk fix` 显式修复。用法与自定义方式见 [`one hk`](/zh/docs/hk/)。Git 未安装或已有 hooks 配置发生冲突时,工作区仍会创建,输出的 `warnings` 会提示后续执行 `one configure hooks`。 持续集成默认不配置。创建工作区不会写入 `.github/workflows/`;添加项目后如有 需要,再显式运行 `one ci enable `。 @@ -83,13 +91,15 @@ one configure add env/infisical --profile work \ "project_name": "my-app", "created_path": "/abs/path/my-app", "created_in_place": false, - "package_manager": "pnpm", + "package_manager": "", "secrets_backend": "dotenv", "ci_enabled": false, "dev_enabled": true } ``` +`package_manager` 在空工作区或纯 Go 工作区中为空字符串;含 Node 项目的 preset 会返回实际包管理器名称。 + `secrets_backend` 是 env 域 backend 名(`dotenv` / `infisical`);`ci_enabled` 为兼容 wire format 继续保留,默认是 `false`,`dev_enabled` 是 `true`。部署配置会在首次部署时写入。 @@ -128,7 +138,7 @@ one create . --yes one create my-app --yes cd my-app one add nestjs-api --name api --yes -pnpm install +one dev api ``` ## 错误恢复 diff --git a/apps/docs/content/docs/zh/dev.md b/apps/docs/content/docs/zh/dev.md index 4bfc32a..c0b0460 100644 --- a/apps/docs/content/docs/zh/dev.md +++ b/apps/docs/content/docs/zh/dev.md @@ -5,6 +5,8 @@ description: 启动全部可开发项目,或只启动一个项目。 `one dev` 从 manifest 读取每个项目的开发命令,并用 One CLI 内置 supervisor 运行。 +启用 mise 的 workspace 会自动在每个项目的 mise 工具环境中运行开发命令,仍然使用 `one dev` / `one dev web`,无需增加 runtime 参数。日志前缀、项目选择和整组服务的停止行为继续由原有 supervisor 负责。mise 的安装与旧项目启用见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 + ## 用法 ```bash @@ -17,12 +19,18 @@ one dev [project] [--dry-run] |---|---| | 位置参数 `project` | 只启动一个项目;支持 manifest 里的 `name` 或 `relativeDir` | | `-p, --project ` | 为旧脚本和 CI 保留的选择参数 | -| `--dry-run` | 只打印将调用的 supervisor 命令,不启动进程 | +| `--dry-run` | 只打印将调用的 supervisor 命令,不安装工具或依赖、不访问网络、不写文件 | | `-o, --output ` | `json` / `yaml` / `text`(默认按 TTY 检测) | -## 交互模式 +## 依赖准备 + +`one dev` 在启动服务前自动准备所选项目的工具与应用依赖,交互和非交互调用行为一致。 + +- Node:在工作区根目录安装一次。已有锁文件时执行冻结安装,锁文件与项目声明不一致会失败;新工作区首次安装生成锁文件。项目声明、工具版本改变或依赖目录被清理后会重新准备。 +- Go:独立模块下载固定构建列表并补充 `go.sum`;存在 `go.work` 时由 Go 按实际包依赖解析本地成员和外部依赖,按需维护 `go.work.sum`。准备过程不自动运行 `go mod tidy` 或 `go work sync`。 +- 所有准备成功后才启动 supervisor。失败保留底层错误和下载缓存,可修复后重试原命令;取消时停止准备进程。 -选中的 Node 项目缺少依赖时,终端会询问是否运行检测到的包管理器安装命令。确认后安装并继续,拒绝则成功退出;非交互调用返回 `DEPENDENCIES_NOT_INSTALLED` 和准确安装命令。 +`go.work.sum` 不替代各模块发布所需的 `go.sum`。模块声明需要修复时,可显式运行 `one run api -- go mod tidy`。`one run` 保持直接执行命令,不自动安装应用依赖。 ## 运行方式 @@ -38,7 +46,8 @@ one dev apps/web --dry-run | 错误码 | 处理 | |---|---| -| `DEPENDENCIES_NOT_INSTALLED` | 执行 remediation 给出的安装命令,再重试 | +| `RUN_COMMAND_NOT_FOUND` | 检查原生工具安装或 mise 配置 | +| `ONE_CLI_ERROR` | 按底层依赖错误修复网络、锁文件或模块声明后重试 | | `SUBPROJECT_NOT_FOUND` | 使用项目 `name` 或 `relativeDir` | 完整码表:[错误码大全](/zh/docs/error-codes/)。 diff --git a/apps/docs/content/docs/zh/error-codes.md b/apps/docs/content/docs/zh/error-codes.md index a2a5879..8f62d8e 100644 --- a/apps/docs/content/docs/zh/error-codes.md +++ b/apps/docs/content/docs/zh/error-codes.md @@ -649,6 +649,12 @@ deploy/edgeone profile 缺少 EdgeOne API token。 - `configure-edgeone` — 创建 EdgeOne Pages API token 后写入 profile
运行:`one configure add deploy/edgeone --profile --use --token $EDGEONE_API_TOKEN` +### `HOOKS_CONFIG_CONFLICT` + +Existing Git hooks or hk configuration conflict with One's generated setup. + +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 + ### `IMAGE_TAG_NOT_FOUND` Container push target image tag does not exist in the local Docker daemon. @@ -667,6 +673,30 @@ Container build needs a version tag but no subproject buildVersion, Git tag, or - `set-build-version` — 或在 one.manifest.json 里设置 projects[].buildVersion - `create-git-tag` — 或在当前提交上创建 Git tag
运行:`git tag v0.1.0` +### `MISE_CONFIG_CONFLICT` + +A managed mise configuration was modified or changed during generation. + +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 + +### `MISE_INSTALL_FAILED` + +One could not extract, verify, or prepare its bundled mise runtime. + +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 + +### `MISE_NOT_FOUND` + +The explicitly selected mise executable is unavailable. + +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 + +### `MISE_VERSION_UNSUPPORTED` + +The installed mise version is unsupported or could not be read. + +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 + ### `PATCH_CONFLICT` Two configuration fragments contributed conflicting patches to the same backend target. @@ -690,6 +720,18 @@ Preset id failed v1 grammar (bad version / segment shape / unknown code). - `regen-preset` — 用 `one serve` 打开 dashboard 重新挑组合得到新的 preset id(dashboard 页面将在后续版本上线) - `check-syntax` — v1 形如 `1.bgok.fnav.ei` —— 前缀为版本号,段以 `.` 分隔,每段首字符是 f/b/l/e kind +### `RUNTIME_INVALID` + +The selected execution runtime is not builtin or mise. + +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 + +### `RUNTIME_TASK_NOT_FOUND` + +The project does not provide the requested runtime task. + +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 + ### `RUN_COMMAND_NOT_FOUND` one run could not locate the requested executable on PATH. diff --git a/apps/docs/content/docs/zh/hk.md b/apps/docs/content/docs/zh/hk.md new file mode 100644 index 0000000..10d4204 --- /dev/null +++ b/apps/docs/content/docs/zh/hk.md @@ -0,0 +1,67 @@ +--- +title: one hk +description: 使用 hk 统一工作区检查、显式修复和 Git 提交检查。 +--- + +`one hk` 通过 One 内置的 mise 运行固定版本的 hk。Go、JS/TS 和混合工作区使用同一套入口,不需要为了 Git hooks 安装 Husky 或 commitlint。 + +## 日常使用 + +```bash +one hk check # 检查变更文件 +one hk check --all # 检查整个工作区,也可用于 CI +one hk check --plan --json # 查看检查计划,不执行检查 +one hk fix # 显式修复,默认不暂存修改 +one hk validate # 校验 hk 配置 +``` + +One 从工作区根目录运行 hk;指定文件时使用相对工作区根目录的路径,例如 `one hk check apps/web/src/App.tsx`。hk 参数、标准输出和退出码直接透传。 + +新工作区自动安装本地 `pre-commit` 和 `commit-msg` 启动器,继续正常使用 `git commit`。克隆已有工作区后,运行一次: + +```bash +one configure hooks +``` + +Git 启动器记录本次 One 可执行文件的位置,并通过它运行内置 mise;不依赖终端的 mise 激活状态。移动或更换 One 安装位置后,可以重新执行 `one configure hooks`。 + +## 默认检查 + +| 阶段 | 行为 | +|---|---| +| 提交前 | 检查暂存内容;临时保存并恢复未暂存修改,不自动修复或暂存文件 | +| 提交信息 | hk 内置的 Conventional Commits 基础检查,支持中文描述 | +| Go 项目 | 使用该项目的 Go 版本检查 `gofmt` 格式;修复时调用 `gofmt -w` | +| JS/TS 项目 | 使用项目已经声明的 oxlint / oxfmt,按文件筛选并读取项目配置 | +| 其他 Node 工具 | 存在时复用 `lint`、`format:check` 和相应的 `:fix` 脚本;脚本可能检查整个项目 | + +提交检查不调用 Go 模板中包含 `go mod tidy` 的 `task check`。构建、类型检查和测试仍可通过项目原有命令执行,也可以自行加入 hk 配置。 + +工具版本由 mise 提供,首次需要时下载并缓存。mise 本身已经内置在 One 中;hk 是单独安装的工作区工具。JS 检查使用项目依赖,需要先安装依赖;`one dev ` 会准备开发依赖,也可运行 `one mise exec -- pnpm install`。 + +## 配置与项目增量更新 + +- `hk.pkl`:用户维护的根配置,默认继承 `.config/one/hk.pkl`。 +- `.config/one/hk.pkl`:One 生成的默认检查,随 `one add` 更新。 +- `.mise/conf.d/one.toml`:声明固定的 hk 版本。 +- Git hooks:安装到当前仓库的 Git hooks 目录,不写用户全局 Git 配置。 + +在 `hk.pkl` 中增加检查或覆盖设置;One 不会覆盖这个文件。不要直接修改生成文件,它带有内容校验,修改后再次生成会报告冲突。新增自定义 hook 事件时,需要自行接入对应 Git 启动器;One 默认安装两个提交检查事件。 + +## 迁移旧工作区 + +```bash +one configure hooks --dry-run -o json +one configure hooks +one mise exec -- pnpm install +``` + +预览会列出配置、Git 启动器以及将移除的默认文件。只有 One 以前生成的默认 Husky / commitlint 配置会自动迁移;自定义规则、额外的 Husky hook、已有的其他 hook 目录会报告 `HOOKS_CONFIG_CONFLICT`,保留原文件供手动整合。 + +迁移移除根 `package.json` 中的 Husky / commitlint 依赖以及默认 `prepare: husky`,保留已有的 Changesets 等其他配置。最后使用实际包管理器执行安装,以更新锁文件;命令本身不会猜测或重写包管理器锁文件。 + +hk 的基础提交检查不等价于 commitlint 的全部规则。例如自定义的长度、大小写和插件规则,需要显式补充到 `hk.pkl` 中。 + +## CI + +准备 One 和项目依赖后运行 `one hk check --all`。该命令复用本地检查配置,不安装 Git hooks,也不自动修复代码;现有构建和测试步骤继续保留。 diff --git a/apps/docs/content/docs/zh/installation.md b/apps/docs/content/docs/zh/installation.md index 3612dc7..ade2f56 100644 --- a/apps/docs/content/docs/zh/installation.md +++ b/apps/docs/content/docs/zh/installation.md @@ -85,6 +85,33 @@ Windows 归档名是 `one-cli_windows_amd64.zip`。 也就是说升级根本不需要任何 flag,重跑安装命令就行。降级 / 修复才用 `ONE_FORCE`。 +## One 自动管理 mise + +新建 workspace 的 `one run` 和 `one dev` 使用 mise 管理工具环境。**发布的 `one` 文件已内置当前平台的 mise 2026.9.7,无需单独安装,首次使用也无需下载 mise。** One 校验内置压缩包,解压并校验可执行文件后放入自己的缓存;后续运行复用缓存。macOS、Linux 的 x64 / arm64 和 Windows x64 发布文件分别携带对应平台资源。[官方二进制分发说明](https://mise.jdx.dev/installing-mise.html)。 + +不需要激活 shell。配置信任遵循 mise 自身规则;默认模式下执行命令可能自动信任当前配置,设置 `MISE_PARANOID=1` 后需先显式审查并信任配置;Node、Go 等工具下载由 mise 处理,应用依赖仍由包管理器安装,尚未安装的工具和依赖仍可能需要联网。旧 workspace 在显式启用前继续沿用已有工具,详见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 + +缓存位于 `$XDG_CACHE_HOME/one/runtimes/mise///`,未设置时使用 `~/.cache/one/runtimes/mise/`。One 默认使用内置固定版本,仅在子进程 PATH 中加入缓存目录。缓存损坏会从内置资源重新解压,无需联网。内置 mise 随 One 升级;One 会关闭该子进程的 mise 自动升级和更新提示。内置资源会增加 One 发布文件体积;运行时需要可写、可执行的缓存目录。 + +| 变量 | 用途 | +|---|---| +| `ONE_MISE_BINARY` | 显式使用其他 mise 可执行文件的绝对路径,最低支持版本为 2026.9.7 | +| `ONE_RUNTIME=builtin` | 临时诊断时使用机器原有工具,跳过 mise | + +解压、写入或校验失败返回 `MISE_INSTALL_FAILED`;不会执行不完整的文件。修复缓存权限后重试原命令,内置资源损坏时重新安装 One。显式指定的 mise 文件不存在时返回 `MISE_NOT_FOUND`。`--dry-run`、创建项目和生成配置不会解压 runtime。 + +需要访问 mise 的原生命令时,使用 `one mise`,无需把缓存目录加入 PATH: + +```bash +one mise --version +one mise doctor +one mise trust .mise/conf.d/one.toml +one mise trust apps/web/.mise/conf.d/one.toml +one mise exec -- pnpm install +``` + +`trust` 请在审查对应配置后运行;自定义配置同样遵循 mise 的信任规则。安装依赖的例子应在 workspace 根目录执行。`one mise` 原样转发参数、IO 和退出码,不额外注入 One 项目密钥;需要项目密钥时继续使用 `one run`。`one mise --help` 展示 One 的入口说明,不触发解压。 + ## 配置 Provider 凭据 Provider 凭据用顶层 `one configure add / --profile ` 配(一次配全工作区都能用)。当前支持这些 pair: diff --git a/apps/docs/content/docs/zh/meta.json b/apps/docs/content/docs/zh/meta.json index bcd8344..c85a595 100644 --- a/apps/docs/content/docs/zh/meta.json +++ b/apps/docs/content/docs/zh/meta.json @@ -11,6 +11,7 @@ "add", "env-vars", "configure", + "hk", "templates-cmd", "container", "dev", diff --git a/apps/docs/content/docs/zh/run.md b/apps/docs/content/docs/zh/run.md index 20a5eac..54a9d30 100644 --- a/apps/docs/content/docs/zh/run.md +++ b/apps/docs/content/docs/zh/run.md @@ -11,7 +11,7 @@ description: 给任意命令注入项目环境变量,并在解析出的项目 one run [-p ] [--env-provider dotenv|infisical] [--env ] -- [args...] ``` -也可以省略 `--`,但脚本里建议保留,避免把子命令 flag 误解析成 One CLI flag。 +必须使用 `--` 分隔 One 参数和子命令参数。项目也可以写成位置参数,例如 `one run web -- pnpm build`。 ## 参数 @@ -20,11 +20,20 @@ one run [-p ] [--env-provider dotenv|infisical] [--env ] -- ` | 选择项目;不传时从当前目录推导 | | `--env-provider dotenv|infisical` | 强制使用指定 env provider;默认取 workspace manifest | | `--env ` | 使用指定环境;默认取 manifest 的默认环境 | +| `--dry-run` | 仅输出目录、原始参数和 runtime,不执行 mise、不读取密钥、不启动命令 | | `-o, --output ` | 只影响 One CLI 自己的输出;子进程 stdout/stderr 原样透传 | ## 交互模式 -`one run` 没有交互式向导;它只按参数解析项目、环境和子命令。因为后面的命令可能带自己的 flag,脚本里建议保留 `--` 分隔符。 +`one run` 没有交互式向导;它按参数解析项目、环境和子命令。子命令参数放在 `--` 后面。 + +## mise 工具环境 + +命令和原有参数保持不变。根目录存在 One 生成的 `.mise/conf.d/one.toml` 时,`one run` 自动通过 mise 准备项目工具和环境,再注入 One 环境变量并执行原始命令。新 workspace 自动生成该配置;旧 workspace 未启用时继续使用原有工具。 + +覆盖顺序为:父进程环境 → mise 环境 → 当前项目的 One 环境变量。项目目录、参数边界、标准 IO 和应用退出码保持原有语义,不需要 `mise activate`。 + +无需单独安装 mise:发布的 One 已内置固定版本,首次运行从自身解压到缓存,无需下载 mise;之后直接复用。配置信任遵循 mise 自身规则;需要显式审批时设置 `MISE_PARANOID=1`,审查配置后通过 `one mise trust` 授权。离线配置见 [One 自动管理 mise](/zh/docs/installation/#one-自动管理-mise),旧项目启用和版本调整见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 ## 示例 diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt index 90ac69a..e1c285f 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -28,6 +28,8 @@ Use it when a coding agent or engineer needs a predictable workspace contract in - `one configure`: store local environment, deploy, and container settings. - `one env`: manage environment variables. - `one run`: run a project with the selected environment. +- `one hk`: check or explicitly fix workspace files using hk through One's embedded mise. +- `one configure hooks`: configure local Git hooks and migrate default Husky/commitlint setups. - `one deploy`: deploy configured projects. - `one container`: build and push container images. - `one serve`: open the local configuration UI. diff --git a/docs/plans/2026-09-14-mise-runtime-adoption.md b/docs/plans/2026-09-14-mise-runtime-adoption.md new file mode 100644 index 0000000..759c7e3 --- /dev/null +++ b/docs/plans/2026-09-14-mise-runtime-adoption.md @@ -0,0 +1,112 @@ +# One CLI 接入 mise + +日期:2026-09-14。状态:第一阶段已在工作区实现,等待用户测试。所有变更保持未提交;用户测试确认后再整理提交,不按阶段制造多个 commit。 + +目标:保持原有命令,让 mise 承担工具版本和运行环境,One 继续提供 workspace、模板、项目选择、密钥和部署语义。 + +## 命令兼容策略 + +```bash +one create my-app -y +cd my-app +one add react-spa --name web -y +one add go-api --name api -y +one dev +one dev web +one run web -- pnpm build +one run api -- go test ./... +``` + +这些命令不增加必填参数,不要求改写成 `mise run`。新 workspace 自动生成配置;根 `.mise/conf.d/one.toml` 存在时,`run` / `dev` 自动使用 mise。旧 workspace 没有该文件则保持原行为,不因升级 One 自动迁移。 + +旧 workspace 的一次性接入和配置修复使用 `one configure mise`,可先加 `--dry-run -o json` 审查文件差异。`ONE_RUNTIME=builtin` 是临时诊断开关;取消该变量即可恢复自动选择。当前不升级 Manifest schema,不新增 `--runtime` 日常参数。 + +## 职责与配置归属 + +| 内容 | 权威来源 | One 的职责 | mise 的职责 | +| --- | --- | --- | --- | +| Workspace、项目名称/路径、模板 | Manifest v1 | 创建、查询、修改 | 使用选定目录 | +| 工具版本 | 生成默认配置 + 用户 mise 覆盖 | 生成确定版本的默认值 | 解析、安装、提供环境 | +| 包管理器版本 | 根 package.json 的 packageManager | 映射精确版本 | 提供包管理器 | +| 应用依赖 | package / pnpm / Go 配置与锁文件 | 调用原有安装入口 | 提供执行工具 | +| dev 命令 | Manifest 的 domains.dev.command | 按项目解析,保持 Dashboard 编辑行为 | 提供工具环境 | +| build/test/lint | 项目 scripts / Taskfile / Go 命令 | 发现能力并生成引用 | 可直接执行生成任务 | +| 项目密钥、环境选择 | One dotenv / Infisical loader | 在执行末端逐项目注入 | 提供环境默认值 | +| 多服务日志与生命周期 | One supervisor | 项目选择、日志、信号、整组停止 | 每个项目的工具环境 | +| CI、部署、镜像、本机连接 | 当前 One 实现 | 本轮保持现有执行路径 | 后续接入 | + +mise 支持工具环境和任务,适合用作 One 的执行基础;首轮不依赖实验性的 daemon、项目图或远程缓存。[工具管理](https://mise.jdx.dev/dev-tools/)、[任务系统](https://mise.jdx.dev/tasks/)、[Monorepo](https://mise.jdx.dev/tasks/monorepo.html)。 + +## 执行路径 + +```text +one run -- + → One 解析项目和运行方式 + → mise exec -- <同一个 One 二进制> __exec ... -- + → __exec 注入所选项目的 One 环境变量 + → 原始 argv / 工作目录 / 标准 IO / 应用退出码 + +one dev [project] + → 原有 supervisor 选择项目 + → 每个项目调用同一个 One 二进制的 run + → 上面的 mise 执行路径 +``` + +`__exec` 是隐藏的内部协议入口,校验协议版本且不再次进入 mise。原始命令按参数数组传递;Manifest shell 命令沿用对应平台的 shell 语义。环境覆盖顺序为父进程 → mise → 当前项目的 One 值,最后补充 node_modules/.bin,保留 mise 的工具路径。 + +常规 `one run` / `one dev` 绑定当前 One 二进制。手动运行生成的 mise 任务时默认使用 PATH 中的 One,也支持 `ONE_BINARY_PATH` 指定测试二进制。生成任务只引用项目/操作,不复制 Manifest 或 package scripts 的命令正文。 + +发布的 One 文件内置当前平台的 **mise 2026.9.7** 官方压缩包,用户只安装 One。首次运行校验内置包,从自身解压可执行文件,校验后原子写入私有缓存;运行时不下载 mise,并在内置 runtime 子进程中关闭 mise 自身的自动升级和更新检查。默认始终使用内置固定版本;`ONE_MISE_BINARY` 可显式指定兼容程序(最低 **2026.9.7**),指定程序不可用或版本不支持时直接报告错误。无需 shell 激活。[mise exec](https://mise.jdx.dev/cli/exec.html)。 + +运行时缓存位于 `$XDG_CACHE_HOME/one/runtimes/mise///`,默认 `~/.cache/one/runtimes/mise/`。压缩包与可执行文件分别固定 SHA256;并发解压使用文件锁,缓存损坏从内置资源恢复。Linux 使用 musl 发行包;覆盖 One 的五个发布平台,每个 One 文件只携带对应平台资源。`sync-mise` 在构建时准备本机资源,`sync-mise-all` 和 GoReleaser 准备全部平台;资源不加入 Git。保留上游 MIT 许可证。 + +One 仅修改子进程 PATH,不覆盖现有 mise,不额外设置自动信任。mise 默认模式可能自动信任执行中的配置;设置 `MISE_PARANOID=1` 可要求显式授权。创建、配置生成和 dry-run 均不触发解压。代价是 One 文件增大,首次运行需要缓存空间;Node、Go 等工具和应用依赖的下载仍由各自工具处理。 + +提供可选的 `one mise ` 原生转发入口,用同一套 runtime 解析访问 trust、doctor、exec 等操作;该入口不先运行 mise exec,也不额外加载 One 项目密钥,因此未信任配置时也能执行显式 trust。普通 `create/add/dev/run` 仍保持原用法。 + +## 配置与写入边界 + +```text +workspace/ + one.manifest.json + package.json + .mise/conf.d/one.toml # One 管理:根工具和 monorepo config_roots + mise.toml # 可选:用户覆盖,One 不改写 + apps/web/ + package.json + .mise/conf.d/one.toml # One 管理:one:dev/build/test/lint + services/api/ + go.mod + .mise/conf.d/one.toml # One 管理:Go/task 工具和任务引用 +``` + +采用 mise 原生配置片段,避免重写用户 TOML、注释和任务发现规则;同目录用户 `mise.toml` 优先于生成片段。[配置优先级](https://mise.jdx.dev/configuration.html)。 + +生成文件带协议标记和内容校验;用户修改过的内容不会被覆盖。配置计划是纯静态读取,记录输入修订,应用前重新检查;使用文件锁和原子替换,失败时恢复本次已写文件并报告恢复错误。已知冲突在添加项目之前检查;添加成功后若配置写入失败,保留项目与 Manifest,明确提示修复后执行 `one configure mise`,不恢复可能覆盖并发编辑的旧 Manifest 快照。 + +Node 默认 `24.15.0`,符合当前模板声明;包管理器从根 packageManager 取得。Go 根据 go.mod 的 go/toolchain 取得,显式 override 不允许低于最低 go 要求。重复生成沿用工具 override,无变化时无 diff。 + +本轮只提供精确版本默认声明;完整 npm engines 范围校验、用户覆盖冲突诊断、跨平台工具锁文件生成尚未实现。用户指定 Node 版本时需确认 engines 兼容性。每个配置根保留独立锁文件布局,不启用新的统一 monorepo 锁模式。[mise.lock](https://mise.jdx.dev/dev-tools/mise-lock.html)。 + +## 分阶段推进 + +| 阶段 | 交付与边界 | 验收 | +| --- | --- | --- | +| 第一阶段:当前试用 | 原命令自动接入、runtime 端口、创建/添加配置、静态预览、生成任务、原 supervisor | 参数/目录/退出码、项目环境隔离、动态 dev 命令、幂等/冲突、双服务停止、仓库检查 | +| 第二阶段:工具与平台完善 | Node engines、包管理器冲突、干净机器工具安装、所有平台锁文件、macOS/Windows 实机兼容 | 原生平台 shell/TTY/信号,固定工具下载结果,旧项目接入/回退 | +| 第三阶段:CI 和部署构建 | 固定版本 mise、与本地共用工具和受支持的构建任务 | 构建失败不上传,不重复 build,产物契约不变,非交互信任与缓存 | +| 第四阶段:收尾 | 诊断展示、安装体验、模板/技能说明、评估可删除代码 | 用户体验验证,再决定是否替换其他执行逻辑 | + +CI 与部署必须分别梳理 Cloudflare/EdgeOne 的本地 build、S3-compatible 的独立 build、Docker 镜像构建和 Vercel 的远程构建;不能假设宿主 mise 环境自动适用于镜像。当前 One 自身 Taskfile 继续作为贡献者入口。 + +保留 supervisor 是第一阶段的正式方案。完整替换前须验证 Expo 终端输入、长日志、孙进程和强制清理;mise interactive/raw 的标准 IO 约束以及 daemon 的生命周期不等同于 One 前台多服务契约。[任务配置](https://mise.jdx.dev/tasks/task-configuration.html)、[Daemons](https://mise.jdx.dev/daemons.html)。 + +## 测试与交付 + +- 使用临时 workspace、假密钥和本机服务验证,不连接真实部署账户。 +- `mise_runtime_test.go` 验证原参数、退出码、环境顺序、静态预览、自动生成、冲突预检和真实生成任务。 +- `mise_runtime_unix_test.go` 验证 Ctrl-C / 子服务失败之后双服务释放端口。 +- 设置 `ONE_TEST_MISE_BINARY` 可启用真实 mise 集成;环境与任务测试通过用户 system 工具覆盖避免下载,不能替代干净机器安装验收。 +- installer 测试覆盖并发解压、tar/zip 校验、缓存损坏恢复、摘要失败和取消清理,并验证内置的真实官方压缩包。Linux 默认 e2e 在没有 PATH mise、不可用代理、空缓存的临时 workspace 中验证原始 One 命令首次执行和缓存复用。`ONE_TEST_MISE_BINARY` 可额外指定真实 mise 验证配置分层、信任和任务。 +- 执行仓库规定的 `task check`,进程和配置相关包补充 race 检查;Windows 交叉编译不能代替实机验证。 +- 用户测试完成后再提交。后续阶段按实际验收推进,本轮不宣称 CI、部署、完整锁文件或跨平台实机验证已完成。 diff --git a/docs/plans/2026-09-14-polyglot-workspace-and-dependencies.md b/docs/plans/2026-09-14-polyglot-workspace-and-dependencies.md new file mode 100644 index 0000000..35c5a45 --- /dev/null +++ b/docs/plans/2026-09-14-polyglot-workspace-and-dependencies.md @@ -0,0 +1,149 @@ +# 多语言工作区与依赖初始化 + +日期:2026-09-14。状态:已实现并通过自动检查与 Linux 真实运行验收;等待用户测试后再提交。 + +延续 [mise 接入方案](./2026-09-14-mise-runtime-adoption.md):保留现有 `create/add/dev/run` 用法,继续内置 mise,不增加额外二进制压缩。用户测试确认后再提交;实现阶段不按每个小步骤提交 commit。 + +## 目标和当前证据 + +目标是让同一个 One workspace 自然容纳纯 Node、纯 Go,以及 Node + Go 项目。首个 Go 模块加入时自动初始化 Go monorepo,首个 JS/TS 项目加入时自动初始化 Node monorepo;后续项目增量加入对应语言的工作区。用户添加项目后,正常执行 `one dev` 即可完成必要的环境准备;两套语言配置可以共存。 + +修复前的报错发生在工具安装完成之后,Go 编译器报告缺少依赖的 `go.sum` 记录。当时的代码中有三个直接相关的事实: + +- `internal/core/template/render.go` 将原始 `go.mod`、`go.sum` 视为模板开发隔离文件并排除。项目的 `go.mod` 来自 `go.mod.hbs`,但没有对应的项目 `go.sum.hbs`。 +- `internal/transport/cobra/dev/cmd.go` 的 `ensureDependencies` 只处理 Node 项目。 +- `internal/core/workspace/summary.go` 的 `ProjectDependenciesInstalled` 对非 Node 项目直接返回 true。 + +另外,修复前的 `creation/workspace_files.go` 无条件生成根 package.json、pnpm workspace、Husky、Changesets、commitlint。这个默认值使纯 Go workspace 也带上 Node 工具,但不是此次 Go 校验错误的直接原因。 + +## 配置的职责 + +| 内容 | 管理者 | 生成时机 | +| --- | --- | --- | +| 项目身份、路径、toolchain | one.manifest.json | create / add | +| 工具版本和运行环境 | mise 配置 | 按实际项目能力刷新 | +| Node 项目与包依赖 | 根 package.json、pnpm-workspace.yaml;安装时维护 pnpm-lock.yaml | 首个 JS/TS 项目加入时初始化,后续增量维护 | +| Go 模块与第三方依赖 | 各项目自己的 go.mod、go.sum | 模板生成及模块依赖准备 | +| Go 模块的工作区成员关系 | 根 go.work;Go 按需维护 go.work.sum | 首个 Go 模块加入时初始化并登记,后续增量维护 | + +pnpm workspace 负责 Node 包的工作区关系。[pnpm 文档](https://pnpm.io/workspaces)。Go 的 `go.work` 可以从一个模块开始,后续通过 `use` 加入其他本地模块。[Go 工作区教程](https://go.dev/doc/tutorial/workspaces)。`go mod tidy` 仍以单个模块为单位,不能用根目录的 workspace 操作代替每个模块的依赖维护。[Go 模块文档](https://go.dev/ref/mod#workspaces)。 + +初始化只生成有意义的配置,不创建空的 go.work.sum 或伪造 pnpm-lock.yaml;这些文件由对应工具按需生成,或由模板提供经过校验的锁文件。 + +## 命令与生命周期 + +| 操作 | 目标行为 | +| --- | --- | +| `one create demo` | 生成语言无关的基础目录、Manifest、Git 和根 mise 配置 | +| `one add go-api --name api` | 生成完整 Go 模块;首次加入时初始化根 go.work,并将该模块加入 use;登记项目,配置 Go / Task | +| `one add react-spa --name web` | 首次加入 JS/TS 项目时初始化根 Node workspace;生成并纳入项目,登记并刷新 mise | +| `one create --preset ...` | 全部项目生成后统一刷新工作区能力,避免重复写根文件 | +| `one dev api` | 准备所选 API 所需工具和依赖,再启动服务 | +| `one dev` | 准备选中的开发项目,Node 根安装去重,准备全部成功后再启动 supervisor | +| `one run api -- ` | 保持通用命令语义,不在用户命令之前偷偷运行 tidy 或安装脚本 | +| `--dry-run` | 只展示可静态计算的项目、工具和准备计划,不执行工具、网络或写文件 | + +`create/add` 保持生成配置的操作,不把联网安装塞进模板渲染事务。默认开发路径由 `dev` 完成准备;需要手工维护时仍可使用现有 `one run api -- go mod tidy` 或 `one mise exec -- pnpm install`。 + +## 第一阶段:修复 Go 首次启动 + +先完成模板和依赖准备的正确性修复。第二阶段接入首个模块触发的语言工作区初始化;新工作区流程的完整交付必须包含这一行为。 + +### 模板交付完整性 + +1. 将模板开发用模块文件与生成项目用模块文件明确分开。保留原始 go.mod / go.sum 的排除规则,新增明确交付给项目的 go.sum.hbs,或等价的显式资源声明。 +2. 以完整渲染后的项目维护和校验 go.mod.hbs / go.sum.hbs,不能在含有未渲染 `.go.hbs` 的模板目录中直接 tidy 后当成发布结果。 +3. 渲染不同项目名,验证内部 module import 替换正确,依赖校验记录保持一致。仅标准库的 Go 模板允许不存在 go.sum。 +4. 将渲染产物的只读依赖解析和编译加入模板验收,避免“模板生成成功”掩盖“首次运行失败”。 + +### 统一依赖准备 + +提取独立的依赖准备服务,内部区分 Node 和 Go;Cobra 只负责参数、进度和结果展示。 + +- Node:继续在工作区根目录安装一次,沿用现有包管理器选择规则,不逐个项目执行安装。 +- Go:在目标模块目录,通过相同 mise runtime 使用兼容 Go 版本。独立模块使用临时 `-modfile` 下载固定构建列表,确认模块声明未改变后仅发布 go.sum;工作区模式由 `go list -mod=readonly -buildvcs=false -deps ./...` 按实际包依赖准备本地成员和外部依赖。不能在工作区中无条件下载 `all`,否则可能查找本地成员未发布的历史版本。 +- 自动准备允许补充 go.sum;不得隐式升级已有依赖版本、替换 go.mod 或忽略校验失败。具体 Go 命令组合先通过缺失 sum、只含 go.mod 校验、缺少 indirect requirement 等 fixtures 验证。 +- 不把 `go mod tidy` 放在每次 dev 前无条件执行。若需要改变模块声明,返回项目名、模块目录和明确的 tidy 修复命令;现有问题可先用 `one run api -- go mod tidy` 修复。 +- 不在 `run` 中设置安装钩子,避免执行 tidy 本身时又触发依赖准备,以及影响用户自定义命令。 +- 沿用语言工具自己的缓存。One 只保存必要的准备状态;状态不能只凭 go.sum 存在、node_modules 目录存在或旧的“成功”标记判定。输入变化、缓存被清理和工具版本变化都需要重新验证。 + +`go mod tidy` 可能增删模块声明、增删校验记录,因此它应作为明确的维护动作;自动下载和 tidy 必须分开。[Go tidy 说明](https://go.dev/ref/mod#go-mod-tidy)。 + +### 失败和状态 + +- 准备失败时不启动任何选中的服务,保留已生成项目和成功下载的缓存,允许原命令重试。 +- 网络错误、模块声明不完整、校验不匹配、工具缺失分别报告,保留底层错误详情;不把网络错误统一当成“执行 tidy 即可”。 +- 日常 `dev` 自动完成普通缺失依赖的准备,不再让用户逐个切目录安装。非交互执行同样给出确定的结果和退出码,日志进入 stderr。 +- 对已有非交互脚本增加说明:首次 dev 可能联网下载;若仓库锁文件不一致需要更新,明确失败,不静默更新锁文件。先确保仓库已有锁文件完整。 +- Go 依赖状态区分“未验证”和“确定缺失”,避免工作区摘要继续对全部 Go 项目报告已就绪。现有输出字段保持兼容,新增状态字段采用可选方式。 +- 准备按根 Node workspace / Go 模块加锁,避免两个 `one dev` 同时写同一份依赖元数据。命令取消必须停止准备进程,释放锁,不进入 supervisor。 +- 传递 provider 返回的完整 argv、cwd 和 env;当前 Node 安装路径只取 prepared.Argv,这一点随提取服务一并修正。 + +## 第二阶段:首个项目触发语言 monorepo 初始化 + +### 新工作区 + +基础 create 仅生成公共文件。由 Manifest 中的项目 toolchain 和实际模块配置识别语言能力,首次添加对应语言项目时,自动完成该语言 monorepo 的初始化。一个 Go 模块也创建 go.work,用户无需额外执行 go work init。 + +| 当前状态 | 添加项目 | 根目录和成员关系变化 | +| --- | --- | --- | +| 空工作区 | 首个 Go 模块 | 创建 go.work,use 包含新模块路径;配置 Go / Task | +| 空工作区 | 首个 JS/TS 项目 | 默认创建 pnpm 所需的根 package.json、pnpm-workspace.yaml 和 Node 开发工具配置;确保新项目被 workspace 覆盖 | +| 已有 Go | 首个 JS/TS 项目 | 增量初始化 Node workspace,保留 go.work 和 Go 配置 | +| 已有 JS/TS | 首个 Go 模块 | 增量初始化 go.work 并登记新模块,保留 Node workspace 配置 | +| 已有对应语言 | 同语言后续项目 | Go 增量维护 use;JS/TS 确认现有 workspace 范围覆盖新项目,必要时增量补齐;保留原有成员和配置 | + +初始化、项目登记和 mise 配置刷新使用同一份工作区变更计划:先检查冲突再写入,避免新增项目后缺少对应根配置。重复执行协调操作不产生重复 use 或 workspace 条目;preset 批量创建统一协调,最终结果与逐个 add 一致。对于旧工作区,若根 go.work 缺失,添加 Go 模块时一并登记 Manifest 中已有的 Go 模块。 + +go.work 的 go 版本须满足所有已登记模块;mise 为这些 Go 项目提供兼容工具版本。该规则从首个模块开始生效,Node 项目不因此被强制安装 Go。 + +这会改变新工作区的初始文件集合,但不改变用户命令语法。需要同步更新 create 的提示、JSON 中 package_manager 的含义、文档和快照:尚无 Node 能力时不能继续返回“pnpm 已配置”。Manifest v1 的项目 toolchain 已足够作为依据,首轮不新增全局“工作区语言”字段。 + +工作区 Git hooks 统一由 hk 提供,不生成 Husky 或 commitlint。纯 Go、JS 和混合工作区均不默认生成 Changesets 配置或依赖;版本管理和发布流程由项目按需配置。 + +### 旧工作区与用户配置 + +- 不删除现有 package.json、pnpm 配置、Git hooks 或用户 mise.toml,不因升级自动清理 Node 文件。 +- 已有根 package.json 表示用户可能使用 Node 工具,即使 Manifest 暂无 Node 项目也要保留其工具需求。 +- `add` 新增语言能力时先检查冲突。文件不存在才生成;用户文件仅合并必要且无冲突的字段,遇到不兼容包管理器配置给出具体差异。已有 Node 工作区沿用其包管理器,不强制改为 pnpm。 +- 已有用户 go.work 保留 use、replace、toolchain、注释及仓库外路径。仅增量加入缺失的模块路径;如解析失败、版本约束冲突或无法安全保留现有内容,在写入前报告具体冲突,不覆盖整个文件。 +- 不通过运行子进程、写 .env 或加载项目密钥来判断工作区类型。 +- 现有 `one configure mise` 继续负责刷新 mise 配置,不能悄悄变成清理整个工作区的命令。 + +## 第三阶段:Go 多模块协作与一致性验收 + +根 go.work 已在首个 Go 模块加入时建立,后续 Go 模块通过同一协调逻辑增量加入 use。本阶段完善和验证模块之间的本地引用、依赖准备和冲突诊断,不再另设初始化门槛。 + +- 从第二阶段初始化开始,One 新生成的 go.work 带初始化说明,使用 Go 官方 modfile 解析器增量维护 use 路径;写入前校验读取快照,并使用文件锁和原子替换。go.work 可以由用户编辑,已有注释和指令按增量合并规则保留。 +- 验证已有用户 go.work 的增量合并,确保 use、replace、toolchain、注释和仓库外路径均保留。 +- 验证工作区 Go 版本满足所有成员模块及 go.work 的要求,避免根版本高、项目 mise 配置覆盖成低版本。 +- go.work 会影响整个 Go 构建图及依赖选取。工作区模式下以该构建图准备依赖,不能把相关本地模块当成远程依赖下载,也不能承诺只下载单个模块的依赖。 +- 不在普通 add/dev 中自动执行 go work sync,因为它可能修改成员 go.mod。每个模块继续维护自己的 go.sum,go.work.sum 不替代它。 +- 新生成且仅含仓库内成员的 go.work 纳入项目版本控制;包含个人外部路径的用户文件保持用户策略。Go 库发布前额外验证 GOWORK=off 能独立构建;纯本地、未发布共享模块需要单独声明发布边界。 +- 模块删除、移动、重复 module path、外部 GOWORK 覆盖和多个 Go 版本要求都纳入冲突诊断;不扫描整块磁盘自动登记模块。 + +## 代码落点和验收 + +| 阶段 | 主要代码落点 | 核心验收 | +| --- | --- | --- | +| 1 | core/template/render、go-api 模板资源、modules/dependencies(新增)、dev/cmd、core/workspace/summary、bootstrap | 从生成到 API 首次启动,覆盖缺失 sum 和空依赖缓存;重复启动复用缓存 | +| 2 | creation/workspace_files、workspace_content、service、project、modules/gowork(新增)、miseconfig、create/add 输出 | 首个 Go 即生成 go.work 并含该模块;首个 JS/TS 即生成 Node workspace;纯 Go 无强制 Node;两种添加顺序和 preset 得到一致能力;后续登记幂等;旧项目和用户文件保留 | +| 3 | modules/gowork、creation/project、miseconfig、依赖准备服务 | 第二个及后续 Go 模块增量登记;API 引用本地 Go 库;修改库后 API 使用本地代码;用户 go.work 安全合并;Go 版本满足整个构建图 | + +模块服务通过 ports/runtime 调用工具;解析和计划逻辑与网络/进程执行分离,遵守现有架构依赖规则。直接调用现有 Go、pnpm 和 mise 能力,不重新实现模块解析器或依赖求解器。 + +每个阶段需要验证 dry-run 无副作用、Windows 路径含空格、明确的失败退出码、并发准备、取消和重试。真实模板测试用临时项目及隔离缓存,先验证依赖解析/编译,再验证服务启动;不将数据库连接失败混同为依赖失败。网络受限的常规测试使用本地模块代理 fixture;发布前补一次真实工具与真实模板验收。 + +实施后执行 task check,对锁和进程生命周期执行相关 race 检查。macOS/Windows 的真实运行验收独立记录,交叉编译不能替代。 + +建议按 1 → 2 → 3 实施,每阶段提供可测试结果。第一阶段解决当前依赖报错;第二阶段交付“第一个 Go / JS 项目即自动初始化对应 monorepo”的默认行为;第三阶段补齐多个 Go 模块协作的验收。阶段划分是实现顺序,最终用户流程以首次添加就完成初始化为准。 + +## 实施记录 + +- `creation/languages.go` 负责首次语言配置初始化;Manifest、语言配置和 mise 配置共享文件变更计划,冲突检查完成后发布。新 Node 项目使用实际项目包名和根包管理器,避免重复使用模板包名。 +- 新增 `modules/gowork` 和 `modules/dependencies`。Go 摘要增加可选的 `dependencies_status: unverified`,避免静态检查误报就绪。 +- Node 缓存指纹包含项目声明、根配置、锁文件和实际工具版本。pnpm 12 的锁文件需要解析全部 YAML 文档;仅有 `packageManagerDependencies` / `configDependencies` 的环境文档以及其后的空文档,不视为应用依赖已锁定。已有应用锁文件继续冻结安装。参见 [pnpm 上游说明](https://github.com/pnpm/pnpm/issues/13805)。 +- `go.sum.hbs` 来自完整渲染的 API 项目,经 Go 1.27.0 tidy 与只读依赖解析校验;go.mod 除项目名以外与模板一致。真实渲染模板编译通过。 +- 验证日志:`/tmp/one-polyglot-check.log`、`/tmp/one-polyglot-race.log`、`/tmp/one-polyglot-mise.log`;真实项目在 `/tmp/one-polyglot-acceptance/demo`。 +- Linux 上内置 mise 启动 Go API,`/health` 返回 200;停止后端口释放。Windows amd64 和 macOS arm64 已交叉编译,尚未在对应操作系统实际运行。 +- JS 首次 `one dev web` 自动生成应用锁文件、完成依赖安装并返回 HTTP 200;第二次启动复用缓存,正常停止。生成的 Go 库通过 `GOWORK=off go test` 独立验收。 diff --git a/packages/cli/cmd/one/main.go b/packages/cli/cmd/one/main.go index 8ade3ae..909a2f4 100644 --- a/packages/cli/cmd/one/main.go +++ b/packages/cli/cmd/one/main.go @@ -8,6 +8,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/bootstrap/cli" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" ) // version is overridden at build time via -ldflags. Keep the source fallback @@ -16,6 +17,10 @@ var version = "0.0.0-dev" func main() { if err := cli.Execute(version, os.Args[1:]); err != nil { + var exit *platformprocess.ExitStatus + if errors.As(err, &exit) { + os.Exit(exit.Code) + } // cli.Execute already emits the structured error envelope; we only // need to surface the non-zero exit status here. var cliErr *output.Error diff --git a/packages/cli/go.mod b/packages/cli/go.mod index b681ac8..f433d9a 100644 --- a/packages/cli/go.mod +++ b/packages/cli/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 github.com/torchstellar-team/one-cli/packages/kernel v0.0.0 + golang.org/x/mod v0.31.0 golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/packages/cli/go.sum b/packages/cli/go.sum index 94782bb..eacec13 100644 --- a/packages/cli/go.sum +++ b/packages/cli/go.sum @@ -224,6 +224,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQz golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= diff --git a/packages/cli/internal/adapters/runtime/mise/embed_darwin_amd64.go b/packages/cli/internal/adapters/runtime/mise/embed_darwin_amd64.go new file mode 100644 index 0000000..717d2d5 --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/embed_darwin_amd64.go @@ -0,0 +1,8 @@ +//go:build darwin && amd64 + +package mise + +import _ "embed" + +//go:embed assets/mise-darwin-amd64.tar.gz +var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_darwin_arm64.go b/packages/cli/internal/adapters/runtime/mise/embed_darwin_arm64.go new file mode 100644 index 0000000..9c8115e --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/embed_darwin_arm64.go @@ -0,0 +1,8 @@ +//go:build darwin && arm64 + +package mise + +import _ "embed" + +//go:embed assets/mise-darwin-arm64.tar.gz +var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_linux_amd64.go b/packages/cli/internal/adapters/runtime/mise/embed_linux_amd64.go new file mode 100644 index 0000000..b65c646 --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/embed_linux_amd64.go @@ -0,0 +1,8 @@ +//go:build linux && amd64 + +package mise + +import _ "embed" + +//go:embed assets/mise-linux-amd64.tar.gz +var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_linux_arm64.go b/packages/cli/internal/adapters/runtime/mise/embed_linux_arm64.go new file mode 100644 index 0000000..592e83b --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/embed_linux_arm64.go @@ -0,0 +1,8 @@ +//go:build linux && arm64 + +package mise + +import _ "embed" + +//go:embed assets/mise-linux-arm64.tar.gz +var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_other.go b/packages/cli/internal/adapters/runtime/mise/embed_other.go new file mode 100644 index 0000000..cb6da8a --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/embed_other.go @@ -0,0 +1,5 @@ +//go:build (!linux && !darwin && !windows) || (!amd64 && !arm64) || (windows && arm64) + +package mise + +var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_windows_amd64.go b/packages/cli/internal/adapters/runtime/mise/embed_windows_amd64.go new file mode 100644 index 0000000..2f174ad --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/embed_windows_amd64.go @@ -0,0 +1,8 @@ +//go:build windows && amd64 + +package mise + +import _ "embed" + +//go:embed assets/mise-windows-amd64.zip +var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/install.go b/packages/cli/internal/adapters/runtime/mise/install.go new file mode 100644 index 0000000..a621cbe --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/install.go @@ -0,0 +1,225 @@ +package mise + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/gofrs/flock" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/userdirs" +) + +const maxArchiveSize = 256 << 20 +const maxBinarySize = 512 << 20 + +// installer owns only One's private versioned runtime cache. It never changes +// PATH, shell configuration, an existing mise installation, or config trust. +type installer struct { + root string + archive []byte + out io.Writer +} + +func defaultInstaller() (installer, error) { + cache := os.Getenv("XDG_CACHE_HOME") + if cache == "" { + home, err := userdirs.Home() + if err != nil { + return installer{}, err + } + cache = filepath.Join(home, ".cache") + } + if !filepath.IsAbs(cache) { + return installer{}, fmt.Errorf("mise runtime cache must have an absolute path") + } + return installer{ + root: filepath.Join(cache, "one", "runtimes", "mise"), + archive: bundledArchive, out: os.Stderr, + }, nil +} + +func (i installer) ensure(ctx context.Context, a releaseAsset) (string, error) { + dir := filepath.Join(i.root, managedVersion, a.Platform) + target := filepath.Join(dir, a.BinaryName()) + if err := verifyExecutable(target, a.BinarySHA256); err == nil { + return target, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + if len(i.archive) == 0 { + return "", fmt.Errorf("this One build has no bundled mise for the current platform") + } + if len(i.archive) > maxArchiveSize || fmt.Sprintf("%x", sha256.Sum256(i.archive)) != a.ArchiveSHA256 { + return "", fmt.Errorf("bundled mise archive SHA256 mismatch") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + lock := flock.New(filepath.Join(dir, ".install.lock")) + locked, err := lock.TryLockContext(ctx, 100*time.Millisecond) + if err != nil { + return "", err + } + if !locked { + return "", fmt.Errorf("could not lock mise runtime installation") + } + defer lock.Unlock() + // Another process (for example the other half of `one dev`) may have + // finished installing while we waited for the cross-process lock. + if err := verifyExecutable(target, a.BinarySHA256); err == nil { + return target, nil + } + if i.out != nil { + fmt.Fprintf(i.out, "[one] Preparing bundled mise %s (%s); extracting to the One runtime cache.\n", managedVersion, a.Platform) + } + + binary, err := os.CreateTemp(dir, ".extract-*") + if err != nil { + return "", err + } + defer os.Remove(binary.Name()) + err = extractBinary(ctx, i.archive, a, binary) + if err == nil { + err = binary.Sync() + } + closeErr := binary.Close() + if err != nil { + return "", err + } + if closeErr != nil { + return "", closeErr + } + if err := verifyFile(binary.Name(), a.BinarySHA256, maxBinarySize); err != nil { + return "", fmt.Errorf("mise executable verification failed: %w", err) + } + if err := ctx.Err(); err != nil { + return "", err + } + if err := os.Chmod(binary.Name(), 0o755); err != nil { + return "", err + } + if err := fsutil.ReplaceFile(binary.Name(), target); err != nil { + return "", err + } + return target, fsutil.SyncDir(dir) +} + +func verifyExecutable(path, expected string) error { + if err := verifyFile(path, expected, maxBinarySize); err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return err + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("cached mise is not executable") + } + return nil +} + +func verifyFile(path, expected string, limit int64) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() > limit { + return fmt.Errorf("invalid cached file: %s", path) + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + hash := sha256.New() + if err := copyLimited(hash, f, limit); err != nil { + return err + } + if fmt.Sprintf("%x", hash.Sum(nil)) != expected { + return fmt.Errorf("SHA256 mismatch for %s", filepath.Base(path)) + } + return nil +} + +func copyLimited(dest io.Writer, source io.Reader, limit int64) error { + n, err := io.Copy(dest, io.LimitReader(source, limit+1)) + if err != nil { + return err + } + if n > limit { + return fmt.Errorf("mise archive or executable exceeds size limit") + } + return nil +} + +// Extract only the expected executable into a caller-owned temporary file. +// Archive paths are never joined to a destination or extracted as directories. +func extractBinary(ctx context.Context, archive []byte, a releaseAsset, dest io.Writer) error { + dest = contextWriter{ctx: ctx, dest: dest} + match := func(name string) bool { + name = strings.TrimPrefix(name, "./") + return name == "mise/bin/"+a.BinaryName() || name == a.BinaryName() + } + if a.Format == "zip" { + z, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return err + } + for _, entry := range z.File { + if !match(entry.Name) || !entry.Mode().IsRegular() { + continue + } + r, err := entry.Open() + if err != nil { + return err + } + defer r.Close() + return copyLimited(dest, r, maxBinarySize) + } + } else { + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return err + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + entry, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if match(entry.Name) && entry.Typeflag == tar.TypeReg { + return copyLimited(dest, tr, maxBinarySize) + } + } + } + return fmt.Errorf("mise archive does not contain the expected executable") +} + +// Observe cancellation while inflating large release binaries. +type contextWriter struct { + ctx context.Context + dest io.Writer +} + +func (w contextWriter) Write(p []byte) (int, error) { + if err := w.ctx.Err(); err != nil { + return 0, err + } + return w.dest.Write(p) +} diff --git a/packages/cli/internal/adapters/runtime/mise/install_test.go b/packages/cli/internal/adapters/runtime/mise/install_test.go new file mode 100644 index 0000000..f9f7a6a --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/install_test.go @@ -0,0 +1,226 @@ +package mise + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" +) + +func archiveFixture(t *testing.T, format string, binary []byte) (releaseAsset, []byte) { + t.Helper() + var buf bytes.Buffer + a := releaseAsset{Platform: "test-platform", Format: format, BinarySHA256: fmt.Sprintf("%x", sha256.Sum256(binary))} + if format == "zip" { + z := zip.NewWriter(&buf) + w, err := z.Create("mise/bin/" + a.BinaryName()) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(binary); err != nil { + t.Fatal(err) + } + if err := z.Close(); err != nil { + t.Fatal(err) + } + } else { + gz := gzip.NewWriter(&buf) + tr := tar.NewWriter(gz) + // This unrelated entry must never be extracted to the filesystem. + if err := tr.WriteHeader(&tar.Header{Name: "../../escape", Mode: 0o644, Size: 1}); err != nil { + t.Fatal(err) + } + if _, err := tr.Write([]byte("x")); err != nil { + t.Fatal(err) + } + if err := tr.WriteHeader(&tar.Header{Name: "mise/bin/" + a.BinaryName(), Mode: 0o755, Size: int64(len(binary))}); err != nil { + t.Fatal(err) + } + if _, err := tr.Write(binary); err != nil { + t.Fatal(err) + } + if err := tr.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + } + a.ArchiveSHA256 = fmt.Sprintf("%x", sha256.Sum256(buf.Bytes())) + return a, buf.Bytes() +} + +func TestReleaseAssetsCoverSupportedOnePlatforms(t *testing.T) { + for _, target := range [][2]string{{"linux", "amd64"}, {"linux", "arm64"}, {"darwin", "amd64"}, {"darwin", "arm64"}, {"windows", "amd64"}} { + a, err := assetFor(target[0], target[1]) + if err != nil { + t.Fatal(err) + } + for _, digest := range []string{a.BinarySHA256, a.ArchiveSHA256} { + b, err := hex.DecodeString(digest) + if err != nil || len(b) != sha256.Size { + t.Fatalf("bad digest for %v", target) + } + } + if !strings.Contains(a.Filename(), managedVersion) { + t.Fatal("unversioned asset") + } + } + if _, err := assetFor("plan9", "amd64"); err == nil { + t.Fatal("unsupported platform accepted") + } +} + +func TestManagedInstallConcurrentOfflineReuseAndRepair(t *testing.T) { + for _, format := range []string{"tar.gz", "zip"} { + t.Run(format, func(t *testing.T) { + binary := []byte("verified executable fixture") + a, archive := archiveFixture(t, format, binary) + var preparations countWriter + root := t.TempDir() + i := installer{root: root, archive: archive, out: &preparations} + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := i.ensure(context.Background(), a); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + if preparations.Load() != 1 { + t.Fatalf("extracted %d times", preparations.Load()) + } + path, err := i.ensure(context.Background(), a) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "escape")); !os.IsNotExist(err) { + t.Fatal("archive escaped destination") + } + if err := os.WriteFile(path, []byte("damaged"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := i.ensure(context.Background(), a); err != nil { + t.Fatal(err) + } + if preparations.Load() != 2 { + t.Fatal("cache was not repaired") + } + if _, err := i.ensure(context.Background(), a); err != nil { + t.Fatalf("offline cache: %v", err) + } + }) + } +} + +func TestManagedInstallFailureNeverPublishesOrReplacesBinary(t *testing.T) { + for _, failure := range []string{"missing-archive", "archive-digest", "binary-digest", "cancelled"} { + t.Run(failure, func(t *testing.T) { + a, archive := archiveFixture(t, "tar.gz", []byte("expected binary")) + if failure == "archive-digest" { + a.ArchiveSHA256 = strings.Repeat("0", 64) + } + if failure == "binary-digest" { + a.BinarySHA256 = strings.Repeat("0", 64) + } + if failure == "missing-archive" { + archive = nil + } + i := installer{root: t.TempDir(), archive: archive} + dir := filepath.Join(i.root, managedVersion, a.Platform) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, a.BinaryName()) + if err := os.WriteFile(path, []byte("previous file"), 0o755); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if failure == "cancelled" { + cancel() + } + if _, err := i.ensure(ctx, a); err == nil { + t.Fatal("bad install succeeded") + } + raw, err := os.ReadFile(path) + if err != nil || string(raw) != "previous file" { + t.Fatalf("previous binary modified: %v %q", err, raw) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".extract-") { + t.Errorf("temporary file leaked: %s", entry.Name()) + } + } + }) + } +} + +func TestMissingBundleHasNoSideEffects(t *testing.T) { + a, _ := archiveFixture(t, "tar.gz", []byte("binary")) + root := filepath.Join(t.TempDir(), "not-created") + i := installer{root: root} + if _, err := i.ensure(context.Background(), a); err == nil { + t.Fatal("missing runtime accepted") + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatal("missing bundle wrote files") + } +} + +// The real per-platform resource must unpack to the separately pinned digest. +func TestBundledOfficialArchive(t *testing.T) { + a, err := currentAsset() + if err != nil { + t.Skip(err) + } + i := installer{root: t.TempDir(), archive: bundledArchive} + if _, err := i.ensure(context.Background(), a); err != nil { + t.Fatal(err) + } +} + +// sync-mise-all makes foreign release resources available for cross-builds. +// Validate their executable digests too, without executing foreign binaries. +func TestPreparedCrossPlatformArchives(t *testing.T) { + for _, target := range [][2]string{{"linux", "amd64"}, {"linux", "arm64"}, {"darwin", "amd64"}, {"darwin", "arm64"}, {"windows", "amd64"}} { + t.Run(target[0]+"-"+target[1], func(t *testing.T) { + a, err := assetFor(target[0], target[1]) + if err != nil { + t.Fatal(err) + } + archive, err := os.ReadFile(filepath.Join("assets", "mise-"+target[0]+"-"+target[1]+"."+a.Format)) + if os.IsNotExist(err) { + t.Skip("cross-platform resources are prepared by task sync-mise-all") + } + if err != nil { + t.Fatal(err) + } + i := installer{root: t.TempDir(), archive: archive} + if _, err := i.ensure(context.Background(), a); err != nil { + t.Fatal(err) + } + }) + } +} + +type countWriter struct{ atomic.Int32 } + +func (w *countWriter) Write(p []byte) (int, error) { w.Add(1); return len(p), nil } diff --git a/packages/cli/internal/adapters/runtime/mise/mise.go b/packages/cli/internal/adapters/runtime/mise/mise.go new file mode 100644 index 0000000..7bc2604 --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/mise.go @@ -0,0 +1,151 @@ +// Package mise prepares commands through an explicitly selected or bundled mise CLI. +package mise + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" +) + +type Provider struct{} + +func (p Provider) Prepare(ctx context.Context, command runtimeport.Command) (runtimeport.Command, error) { + command.Argv = append([]string{"exec", "--"}, command.Argv...) + return p.PrepareCLI(ctx, command) +} + +func (Provider) PrepareCLI(ctx context.Context, command runtimeport.Command) (runtimeport.Command, error) { + path, err := resolveBinary(ctx, command) + if err != nil { + return command, err + } + command.Argv = append([]string{path}, command.Argv...) + command.Env = prependRuntimePath(command.Env, filepath.Dir(path)) + if os.Getenv("ONE_MISE_BINARY") == "" { + command.Env = pinnedRuntimeEnv(command.Env) + } + return command, nil +} + +func resolveBinary(ctx context.Context, command runtimeport.Command) (string, error) { + if override := os.Getenv("ONE_MISE_BINARY"); override != "" { + path, err := filepath.Abs(override) + if err != nil { + return "", err + } + if err := checkVersion(ctx, path, command); err != nil { + return "", err + } + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + a, err := currentAsset() + if err != nil { + return "", cliErrors.New(cliErrors.MISE_INSTALL_FAILED, err.Error()) + } + i, err := defaultInstaller() + if err != nil { + return "", cliErrors.New(cliErrors.MISE_INSTALL_FAILED, err.Error()) + } + path, err := i.ensure(ctx, a) + if err != nil { + return "", cliErrors.New(cliErrors.MISE_INSTALL_FAILED, "Could not prepare bundled mise: "+err.Error()).WithRemediation(output.Remediation{ + Action: "prepare-mise", Hint: "Ensure the One runtime cache is writable and executable, then retry. Reinstall One if its bundled archive is damaged, or set ONE_MISE_BINARY to a compatible executable. ONE_RUNTIME=builtin uses existing tools for diagnostics.", + }) + } + // The executable digest already pins the bundled version. Probing it again + // would add startup work and mise's default online update check. + return path, nil +} + +func checkVersion(ctx context.Context, path string, command runtimeport.Command) error { + if _, err := os.Stat(path); err != nil { + return cliErrors.New(cliErrors.MISE_NOT_FOUND, "Cannot access the mise executable: "+err.Error()) + } + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + probe := exec.CommandContext(probeCtx, path, "--version") + probe.Dir, probe.Env = command.Directory, pinnedRuntimeEnv(command.Env) + version, err := probe.Output() + if err != nil { + return cliErrors.New(cliErrors.MISE_VERSION_UNSUPPORTED, "Could not read the mise version: "+err.Error()) + } + if !supportedVersion(string(version)) { + return cliErrors.New(cliErrors.MISE_VERSION_UNSUPPORTED, fmt.Sprintf("mise %s or newer is required.", runtimeport.MinimumMiseVersion)) + } + return nil +} + +// One owns updates to its bundled runtime. These process-local settings also +// keep a --version probe from consulting the network or replacing the binary. +func pinnedRuntimeEnv(env []string) []string { + if env == nil { + env = os.Environ() + } + result := make([]string, 0, len(env)+2) + for _, kv := range env { + key, _, _ := strings.Cut(kv, "=") + if runtime.GOOS == "windows" { + key = strings.ToUpper(key) + } + if key != "MISE_AUTO_UPDATE" && key != "MISE_DISABLE_UPDATE_WARNING" { + result = append(result, kv) + } + } + return append(result, "MISE_AUTO_UPDATE=false", "MISE_DISABLE_UPDATE_WARNING=true") +} + +func prependRuntimePath(env []string, dir string) []string { + if env == nil { + env = os.Environ() + } + result := append([]string(nil), env...) + for i, kv := range result { + key, value, found := strings.Cut(kv, "=") + if found && (key == "PATH" || runtime.GOOS == "windows" && strings.EqualFold(key, "PATH")) { + result[i] = key + "=" + dir + string(os.PathListSeparator) + value + return result + } + } + return append(result, "PATH="+dir) +} + +func supportedVersion(value string) bool { + fields := strings.Fields(value) + if len(fields) == 0 { + return false + } + parts := strings.Split(strings.TrimPrefix(fields[0], "v"), ".") + if len(parts) != 3 { + return false + } + minimum := []int{2026, 9, 7} + comparison := 0 + for i, part := range parts { + n, err := strconv.Atoi(part) + if err != nil || n < 0 { + return false + } + if comparison == 0 { + if n < minimum[i] { + comparison = -1 + } + if n > minimum[i] { + comparison = 1 + } + } + } + return comparison >= 0 +} diff --git a/packages/cli/internal/adapters/runtime/mise/mise_test.go b/packages/cli/internal/adapters/runtime/mise/mise_test.go new file mode 100644 index 0000000..5ab241e --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/mise_test.go @@ -0,0 +1,58 @@ +package mise + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" +) + +func TestSupportedVersion(t *testing.T) { + for _, tt := range []struct { + version string + want bool + }{ + {"2026.9.7 linux-x64 (2026-09-13)", true}, {"2026.10.0", true}, {"2027.1.0", true}, + {"2026.9.6", false}, {"2026.8.999", false}, {"", false}, {"2026.9.7-dev", false}, {"2026.9.x", false}, + } { + if got := supportedVersion(tt.version); got != tt.want { + t.Errorf("%q: got %v want %v", tt.version, got, tt.want) + } + } +} + +func TestExplicitBinaryIsRespectedAndVersionChecked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX fake executable") + } + dir := t.TempDir() + path := filepath.Join(dir, "private mise") + if err := os.WriteFile(path, []byte("#!/bin/sh\necho 2026.9.7\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("ONE_MISE_BINARY", path) + command := runtimeport.Command{Directory: dir, Argv: []string{"one", "__exec", "--", "a b", ""}, Env: []string{"PATH=/somewhere", "KEEP=unchanged"}} + prepared, err := (Provider{}).Prepare(context.Background(), command) + if err != nil { + t.Fatal(err) + } + if prepared.Argv[0] != path || prepared.Argv[len(prepared.Argv)-1] != "" { + t.Fatalf("argv: %q", prepared.Argv) + } + if !strings.HasPrefix(prepared.Env[0], "PATH="+dir+string(os.PathListSeparator)) { + t.Fatalf("private runtime path missing: %q", prepared.Env) + } + if command.Env[0] != "PATH=/somewhere" { + t.Fatal("mutated caller environment") + } + if err := os.WriteFile(path, []byte("#!/bin/sh\necho 2025.1.0\n"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := (Provider{}).Prepare(context.Background(), command); err == nil { + t.Fatal("unsupported explicit version accepted") + } +} diff --git a/packages/cli/internal/adapters/runtime/mise/miserelease/release.go b/packages/cli/internal/adapters/runtime/mise/miserelease/release.go new file mode 100644 index 0000000..758dc82 --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/miserelease/release.go @@ -0,0 +1,48 @@ +package miserelease + +import ( + "fmt" +) + +// Pin the release and both the archive and extracted binary digests. These +// values come from the official v2026.9.7 SHASUMS256.txt; no runtime "latest" +// resolution or unverified installer script is involved. +const Version = "2026.9.7" +const BaseURL = "https://github.com/jdx/mise/releases/download/v" + Version + +type Asset struct { + Platform string + Format string + ArchiveSHA256 string + BinarySHA256 string +} + +func ForPlatform(goos, goarch string) (Asset, error) { + var a Asset + switch goos + "/" + goarch { + case "linux/amd64": + a = Asset{"linux-x64-musl", "tar.gz", "2b95652a7e946be3fc72b729d3e415813eba74c49eb3a13b96d16b213aaf6c60", "67265e2efa2874bb956e3348b8c2b3eabf9654d80770f92b9b334d059f583962"} + case "linux/arm64": + a = Asset{"linux-arm64-musl", "tar.gz", "85cce4289be0f931609ba2ac179b84875198e948ccb8557a8e842aa15ee117b6", "0e99838cae628b428f84236db29dc6a80edfd90e2dedf81a84d367214300f885"} + case "darwin/amd64": + a = Asset{"macos-x64", "tar.gz", "98d6fa19fbf6022558ffbbf259800fc7f3dd696fdc950b922d7f3f53e75ef36b", "06f763e37615966f0d660f0fd7d4d23568d1b63d5bdb65e2f7a98af2fdc36075"} + case "darwin/arm64": + a = Asset{"macos-arm64", "tar.gz", "f6810aa1609a475ce7f3fdc83eb1e090ace6231d3b1b5aaead944663c4b4b7f1", "3c3f377e7123a466274a20f01502ddd8c58f76028907f471c9bc42fbf83846e1"} + case "windows/amd64": + a = Asset{"windows-x64", "zip", "caa1ca158f04d91f42dc2cd99bb1f69f6b5bfa0d0772d485150120aad9778685", "80552c6a4a03849707cb6154186a22795516388d06bf0760b4418729a42efe43"} + default: + return a, fmt.Errorf("bundled mise is unsupported on %s/%s; set ONE_MISE_BINARY to a compatible executable", goos, goarch) + } + return a, nil +} + +func (a Asset) Filename() string { + return "mise-v" + Version + "-" + a.Platform + "." + a.Format +} + +func (a Asset) BinaryName() string { + if a.Format == "zip" { + return "mise.exe" + } + return "mise" +} diff --git a/packages/cli/internal/adapters/runtime/mise/release.go b/packages/cli/internal/adapters/runtime/mise/release.go new file mode 100644 index 0000000..6bc1fda --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/release.go @@ -0,0 +1,18 @@ +package mise + +import ( + "runtime" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/runtime/mise/miserelease" +) + +const managedVersion = miserelease.Version + +type releaseAsset = miserelease.Asset + +func currentAsset() (releaseAsset, error) { + return miserelease.ForPlatform(runtime.GOOS, runtime.GOARCH) +} +func assetFor(goos, goarch string) (releaseAsset, error) { + return miserelease.ForPlatform(goos, goarch) +} diff --git a/packages/cli/internal/application/execution/operation.go b/packages/cli/internal/application/execution/operation.go new file mode 100644 index 0000000..0cbe814 --- /dev/null +++ b/packages/cli/internal/application/execution/operation.go @@ -0,0 +1,99 @@ +package execution + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "gopkg.in/yaml.v3" +) + +// OperationArgs resolves the live source command, never a copy in generated TOML. +func OperationArgs(w Workspace, selector, operation string) ([]string, error) { + p, ok := w.Project(selector) + if !ok { + return nil, cliErrors.New(cliErrors.SUBPROJECT_NOT_FOUND, "Unknown project: "+selector) + } + if operation == "dev" { + command := workspace.ProjectDev(w.Manifest(), p.Name) + if command == "" { + return nil, missingOperation(p.Name, operation) + } + if runtime.GOOS == "windows" { + shell := os.Getenv("ComSpec") + if shell == "" { + shell = "cmd.exe" + } + return []string{shell, "/d", "/s", "/c", command}, nil + } + return []string{"sh", "-c", command}, nil + } + if operation != "build" && operation != "test" && operation != "lint" { + return nil, missingOperation(p.Name, operation) + } + if p.Toolchain == "node" { + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + raw, err := os.ReadFile(filepath.Join(p.TargetDir, "package.json")) + if err != nil { + return nil, err + } + if err = json.Unmarshal(raw, &pkg); err != nil { + return nil, err + } + manager := p.PackageManager + if rootPkg, err := workspace.ReadPackageJSON(w.Root()); err == nil && rootPkg != nil && rootPkg.PackageManager != "" { + manager = rootPkg.PackageManager + } + manager, _, _ = strings.Cut(manager, "@") + if manager == "" { + manager = "pnpm" + } + switch manager { + case "pnpm", "npm", "yarn", "bun": + default: + return nil, fmt.Errorf("unsupported package manager %q", manager) + } + if pkg.Scripts[operation] == "" { + return nil, missingOperation(p.Name, operation) + } + return []string{manager, "run", operation}, nil + } + if p.Toolchain == "go" { + raw, err := os.ReadFile(filepath.Join(p.TargetDir, "Taskfile.yml")) + if err == nil { + var tasks struct { + Tasks map[string]any `yaml:"tasks"` + } + if err = yaml.Unmarshal(raw, &tasks); err != nil { + return nil, err + } + if _, exists := tasks.Tasks[operation]; exists { + return []string{"task", operation}, nil + } + return nil, missingOperation(p.Name, operation) + } + if !os.IsNotExist(err) { + return nil, err + } + switch operation { + case "build": + return []string{"go", "build", "./..."}, nil + case "test": + return []string{"go", "test", "./..."}, nil + case "lint": + return []string{"go", "vet", "./..."}, nil + } + } + return nil, missingOperation(p.Name, operation) +} + +func missingOperation(project, operation string) error { + return cliErrors.New(cliErrors.RUNTIME_TASK_NOT_FOUND, fmt.Sprintf("Project %s has no %s task.", project, operation)) +} diff --git a/packages/cli/internal/application/execution/runtime.go b/packages/cli/internal/application/execution/runtime.go new file mode 100644 index 0000000..b11fd7f --- /dev/null +++ b/packages/cli/internal/application/execution/runtime.go @@ -0,0 +1,29 @@ +package execution + +import ( + "os" + "path/filepath" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" +) + +// RuntimeKind keeps normal command syntax unchanged. Legacy workspaces retain +// their original execution path until they have explicitly generated mise config. +// ONE_RUNTIME is an escape hatch for diagnostics and compatibility tests. +func RuntimeKind(root string) (string, error) { + if override := os.Getenv("ONE_RUNTIME"); override != "" { + if err := runtimeport.Validate(override); err != nil { + return "", err + } + return override, nil + } + _, err := os.Stat(filepath.Join(root, workspace.MiseConfigFilename)) + if os.IsNotExist(err) { + return runtimeport.Builtin, nil + } + if err != nil { + return "", err + } + return runtimeport.Mise, nil +} diff --git a/packages/cli/internal/application/execution/runtime_test.go b/packages/cli/internal/application/execution/runtime_test.go new file mode 100644 index 0000000..5564cf6 --- /dev/null +++ b/packages/cli/internal/application/execution/runtime_test.go @@ -0,0 +1,35 @@ +package execution + +import ( + "os" + "path/filepath" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" +) + +func TestRuntimeSelectionPreservesLegacyAndUsesGeneratedConfig(t *testing.T) { + t.Setenv("ONE_RUNTIME", "") + root := t.TempDir() + if got, err := RuntimeKind(root); err != nil || got != "builtin" { + t.Fatalf("legacy: %q %v", got, err) + } + path := filepath.Join(root, workspace.MiseConfigFilename) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("min_version = '2026.9.7'\n"), 0o644); err != nil { + t.Fatal(err) + } + if got, err := RuntimeKind(root); err != nil || got != "mise" { + t.Fatalf("configured: %q %v", got, err) + } + t.Setenv("ONE_RUNTIME", "builtin") + if got, err := RuntimeKind(root); err != nil || got != "builtin" { + t.Fatalf("override: %q %v", got, err) + } + t.Setenv("ONE_RUNTIME", "typo") + if _, err := RuntimeKind(root); err == nil { + t.Fatal("invalid runtime was ignored") + } +} diff --git a/packages/cli/internal/bootstrap/cli/dependencies.go b/packages/cli/internal/bootstrap/cli/dependencies.go index 71f93c9..7bf729e 100644 --- a/packages/cli/internal/bootstrap/cli/dependencies.go +++ b/packages/cli/internal/bootstrap/cli/dependencies.go @@ -12,6 +12,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/vercel" "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/dotenv" "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/infisical" + miseruntime "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/runtime/mise" internaltoolchain "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/toolchain" workspaceregistrylocal "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/workspaceregistry/local" ciapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/ci" @@ -24,6 +25,7 @@ import ( creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" deployport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" pkgci "github.com/torchstellar-team/one-cli/packages/cli/pkg/ci" ) @@ -32,6 +34,7 @@ import ( // application services, cohesive feature modules, or narrow real ports; // vertical modules may compose their built-in adapters internally. type dependencies struct { + runtime runtimeport.Provider catalog *catalog.Catalog profiles *configureapp.ProfileService containers *containermodule.Service @@ -56,6 +59,7 @@ func composeDependencies() dependencies { creation := mustCreationService(environments, registry) return dependencies{ + runtime: miseruntime.Provider{}, catalog: backendCatalog, profiles: profiles, containers: containers, diff --git a/packages/cli/internal/bootstrap/cli/root.go b/packages/cli/internal/bootstrap/cli/root.go index 929e728..e918451 100644 --- a/packages/cli/internal/bootstrap/cli/root.go +++ b/packages/cli/internal/bootstrap/cli/root.go @@ -27,6 +27,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/preferences" + platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck" "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/add" "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/ci" @@ -36,6 +37,8 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/deploy" "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/dev" "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/env" + "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/hooks" + "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/mise" "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/run" "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/serve" "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/templates" @@ -62,9 +65,11 @@ func newRootCommand() *cobra.Command { Creation: deps.creation, NewService: deps.newDeploymentService, }), - devcmd.Commands(), + devcmd.Commands(deps.runtime), + misecmd.RuntimeCommands(deps.runtime), + hookscmd.Commands(deps.runtime), envcmd.Commands(envcmd.Dependencies{Service: deps.environments}), - runcmd.Commands(deps.loaders), + runcmd.Commands(deps.loaders, deps.runtime), servecmd.Commands(servecmd.Dependencies{ Catalog: deps.catalog, Profiles: deps.profiles, Workspaces: deps.workspaces, Registry: deps.registry, Manifest: deps.manifest, Environments: deps.environments, @@ -143,8 +148,10 @@ func Execute(version string, args []string) (resultErr error) { // in the defer below from cached state. Both calls are no-ops on // CI / -o json / dev builds / opt-out, so this is free in those // paths. See internal/platform/updatecheck. - updatecheck.MaybeRefreshAsync(version) - defer updatecheck.Notify(version) + if shouldCheckUpdates(args) { + updatecheck.MaybeRefreshAsync(version) + defer updatecheck.Notify(version) + } if shouldRenderAllHelp(args) { helpui.RenderAll(rootCmd, os.Stdout) @@ -197,6 +204,10 @@ func Execute(version string, args []string) (resultErr error) { } if err := rootCmd.Execute(); err != nil { + var exit *platformprocess.ExitStatus + if errors.As(err, &exit) { + return err + } // Wrap unknown errors into the structured envelope so JSON consumers // always see one-cli/error/v1. var cliErr *output.Error @@ -211,6 +222,22 @@ func Execute(version string, args []string) (resultErr error) { return nil } +// Execution leaves and previews must not start another background network check. +func shouldCheckUpdates(args []string) bool { + if first, _ := firstPositional(args); first == "__exec" || first == "mise" || first == "hk" || first == "__hook-gofmt" { + return false + } + for _, arg := range args { + if arg == "--" { + break + } + if arg == "--dry-run" || arg == "--dry-run=true" { + return false + } + } + return true +} + // helpFlags catches the tokens we treat as a request for help. var helpFlags = map[string]struct{}{ "-h": {}, diff --git a/packages/cli/internal/core/template/go_template_test.go b/packages/cli/internal/core/template/go_template_test.go new file mode 100644 index 0000000..a5c04e8 --- /dev/null +++ b/packages/cli/internal/core/template/go_template_test.go @@ -0,0 +1,68 @@ +package template + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/mod/modfile" +) + +func TestGoAPIDeliversChecksumsForRenderedModule(t *testing.T) { + for _, name := range []string{"api", "billing-service"} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + if err := Render("go-api", root, CommonVariables(name, "")); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(root, "go.mod")) + if err != nil { + t.Fatal(err) + } + m, err := modfile.Parse("go.mod", b, nil) + if err != nil { + t.Fatal(err) + } + if m.Module.Mod.Path != "github.com/example/"+name { + t.Fatalf("module=%s", m.Module.Mod.Path) + } + sums, err := os.ReadFile(filepath.Join(root, "go.sum")) + if err != nil { + t.Fatalf("template missing delivered go.sum: %v", err) + } + for _, dep := range m.Require { + if !strings.Contains(string(sums), dep.Mod.Path+" "+dep.Mod.Version+" h1:") { + t.Errorf("missing checksum for %s@%s", dep.Mod.Path, dep.Mod.Version) + } + } + if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(path, ".go") { + b, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(b), "{{projectName") { + t.Errorf("unrendered import in %s", path) + } + } + return nil + }); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestGoLibraryDoesNotNeedEmptyChecksumFile(t *testing.T) { + root := t.TempDir() + if err := Render("go-lib", root, CommonVariables("shared", "")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "go.sum")); !os.IsNotExist(err) { + t.Fatal("standard-library-only module should not need an empty go.sum") + } +} diff --git a/packages/cli/internal/core/workspace/hooks.go b/packages/cli/internal/core/workspace/hooks.go new file mode 100644 index 0000000..b033cee --- /dev/null +++ b/packages/cli/internal/core/workspace/hooks.go @@ -0,0 +1,9 @@ +package workspace + +// HooksConfigFilename is the generated base amended by the user-owned hk.pkl. +// Pin the executable and its embedded Pkl schema together. +const ( + HooksConfigFilename = ".config/one/hk.pkl" + HKVersion = "2.0.0" + HKTool = "aqua:jdx/hk" +) diff --git a/packages/cli/internal/core/workspace/manifest.go b/packages/cli/internal/core/workspace/manifest.go index e695bfd..6ad2977 100644 --- a/packages/cli/internal/core/workspace/manifest.go +++ b/packages/cli/internal/core/workspace/manifest.go @@ -17,6 +17,9 @@ import ( // project root. const ManifestFilename = "one.manifest.json" +// MiseConfigFilename is the managed fragment marking a mise-enabled workspace. +const MiseConfigFilename = ".mise/conf.d/one.toml" + // ManifestVersion is the current manifest schema generation. const ManifestVersion = 1 diff --git a/packages/cli/internal/core/workspace/summary.go b/packages/cli/internal/core/workspace/summary.go index 2fd1b91..7a237be 100644 --- a/packages/cli/internal/core/workspace/summary.go +++ b/packages/cli/internal/core/workspace/summary.go @@ -32,6 +32,7 @@ type SummaryProject struct { RelativeDir string `json:"relative_dir"` CanStartDevelopment bool `json:"can_start_development"` DependenciesInstalled bool `json:"dependencies_installed"` + DependenciesStatus string `json:"dependencies_status,omitempty"` DeploymentConfigured bool `json:"deployment_configured"` } @@ -81,15 +82,20 @@ func BuildSummary(root string) (Summary, error) { projectDir := filepath.Join(root, filepath.FromSlash(p.RelativeDir)) canDevelop := devCommand != "" dependenciesInstalled := ProjectDependenciesInstalled(root, projectDir, p.Toolchain) + dependencyStatus := "" + if p.Toolchain == "go" { + dependencyStatus = "unverified" + } deployConfigured := strings.TrimSpace(DeployForProject(m, p.Name).Backend) != "" s.Projects = append(s.Projects, SummaryProject{ Name: p.Name, RelativeDir: p.RelativeDir, CanStartDevelopment: canDevelop, DependenciesInstalled: dependenciesInstalled, + DependenciesStatus: dependencyStatus, DeploymentConfigured: deployConfigured, }) - if canDevelop && !dependenciesInstalled { + if canDevelop && !dependenciesInstalled && dependencyStatus != "unverified" { s.Issues = append(s.Issues, SummaryIssue{Code: "dependencies_not_installed", Project: p.Name}) } if !canDevelop { @@ -110,6 +116,11 @@ func BuildSummary(root string) (Summary, error) { // declared by the project rather than treating any node_modules directory as // proof that a newly added project was included in the last install. func ProjectDependenciesInstalled(root, projectDir, toolchain string) bool { + // A static summary cannot validate the Go build graph or module cache. + // The development preparation service verifies those through Go itself. + if strings.TrimSpace(toolchain) == "go" { + return false + } if strings.TrimSpace(toolchain) != "node" { return true } @@ -161,6 +172,9 @@ func (s *Summary) RenderTTY(w io.Writer) { if !p.DependenciesInstalled { dev = i18n.T("workspace.dependencies_missing") } + if p.DependenciesStatus == "unverified" { + dev = i18n.T("workspace.dependencies_unverified") + } } deploy := i18n.T("workspace.deploy_missing") if p.DeploymentConfigured { diff --git a/packages/cli/internal/core/workspace/summary_test.go b/packages/cli/internal/core/workspace/summary_test.go index 66678b0..f5e8bb5 100644 --- a/packages/cli/internal/core/workspace/summary_test.go +++ b/packages/cli/internal/core/workspace/summary_test.go @@ -37,11 +37,11 @@ func TestProjectDependenciesInstalledChecksDeclaredNodeDependencies(t *testing.T } } -func TestProjectDependenciesInstalledHandlesNoInstallToolchains(t *testing.T) { +func TestGoDependenciesRequireRuntimeVerification(t *testing.T) { root := t.TempDir() projectDir := filepath.Join(root, "services", "api") - if !ProjectDependenciesInstalled(root, projectDir, "go") { - t.Fatal("non-node toolchains do not have a package-manager install gate") + if ProjectDependenciesInstalled(root, projectDir, "go") { + t.Fatal("a static filesystem check cannot claim Go dependencies are installed") } } diff --git a/packages/cli/internal/modules/creation/artifacts.go b/packages/cli/internal/modules/creation/artifacts.go index d4e60cc..7d7002a 100644 --- a/packages/cli/internal/modules/creation/artifacts.go +++ b/packages/cli/internal/modules/creation/artifacts.go @@ -71,6 +71,9 @@ func syncProject(opts syncProjectOptions) error { } if command := workspace.ResolveScaffoldDevCommand(scripts, string(tc), opts.TargetDir); command != "" { + if tc == toolchain.Node && pm != toolchain.PMpnpm { + command = strings.Replace(command, "pnpm run ", string(pm)+" run ", 1) + } if err := workspace.UpdateProjectDev(opts.ProjectRoot, relDir, command); err != nil { return err } diff --git a/packages/cli/internal/modules/creation/languages.go b/packages/cli/internal/modules/creation/languages.go new file mode 100644 index 0000000..c0da3f5 --- /dev/null +++ b/packages/cli/internal/modules/creation/languages.go @@ -0,0 +1,299 @@ +package creation + +import ( + "bytes" + "encoding/json" + "fmt" + "path" + "path/filepath" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/gowork" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" + "gopkg.in/yaml.v3" +) + +func planLanguages(p *fsutil.FilePlan, m *workspace.Manifest) error { + var nodeDirs, goDirs []string + for _, project := range m.Projects { + rel := filepath.ToSlash(filepath.Clean(project.RelativeDir)) + if err := fsutil.SafeWritePath(p.Root, filepath.Join(p.Root, rel, "one.config")); err != nil { + return err + } + switch project.Toolchain { + case "go": + goDirs = append(goDirs, rel) + case "node": + nodeDirs = append(nodeDirs, rel) + } + } + if len(goDirs) > 0 { + b, _, err := gowork.Build(p.Root, goDirs, p.Read) + if err != nil { + return err + } + if err := p.Set("go.work", b, 0o644); err != nil { + return err + } + } + if len(nodeDirs) > 0 { + return planNodeWorkspace(p, m, nodeDirs) + } + return nil +} + +func nodePackageManager(p *fsutil.FilePlan) (string, error) { + b, err := p.Read("package.json") + if err != nil { + return "", err + } + manager := "" + if b != nil { + var pkg struct { + PackageManager string `json:"packageManager"` + } + if err := json.Unmarshal(b, &pkg); err != nil { + return "", err + } + manager, _, _ = strings.Cut(pkg.PackageManager, "@") + } + for _, lock := range []struct{ file, manager string }{{"pnpm-lock.yaml", "pnpm"}, {"package-lock.json", "npm"}, {"yarn.lock", "yarn"}, {"bun.lock", "bun"}, {"bun.lockb", "bun"}} { + b, err := p.Read(lock.file) + if err != nil { + return "", err + } + if b == nil { + continue + } + if manager != "" && manager != lock.manager { + return "", fmt.Errorf("%s conflicts with package manager %s", lock.file, manager) + } + manager = lock.manager + } + if manager == "" { + manager = "pnpm" + } + switch manager { + case "pnpm", "npm", "yarn", "bun": + return manager, nil + default: + return "", fmt.Errorf("unsupported package manager %q", manager) + } +} + +// Bundled Node templates may carry their development package name and manager. +// Set those for the newly generated project before it joins the workspace. +func configureNodePackage(p *fsutil.FilePlan, dir, name, manager string) error { + raw, err := p.Read(filepath.Join(dir, "package.json")) + if err != nil { + return err + } + var pkg map[string]json.RawMessage + if err := json.Unmarshal(raw, &pkg); err != nil { + return err + } + if pkg == nil { + return fmt.Errorf("project package.json must be an object") + } + pkg["name"], _ = json.Marshal(name) + // The root packageManager is authoritative, including its pinned version. + root, err := p.Read("package.json") + if err != nil { + return err + } + var rootPkg struct { + PackageManager string `json:"packageManager"` + } + if root != nil { + if err := json.Unmarshal(root, &rootPkg); err != nil { + return err + } + } + if rootPkg.PackageManager != "" { + pkg["packageManager"], _ = json.Marshal(rootPkg.PackageManager) + } else { + delete(pkg, "packageManager") + } + if manager != "pnpm" { + var scripts map[string]string + if raw := pkg["scripts"]; len(raw) > 0 { + if err := json.Unmarshal(raw, &scripts); err != nil { + return err + } + } + for key, command := range scripts { + scripts[key] = strings.ReplaceAll(command, "pnpm run ", manager+" run ") + } + if scripts != nil { + pkg["scripts"], _ = json.Marshal(scripts) + } + } + after, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return err + } + return p.Set(filepath.Join(dir, "package.json"), append(after, '\n'), 0o644) +} + +func planNodeWorkspace(p *fsutil.FilePlan, m *workspace.Manifest, dirs []string) error { + manager, err := nodePackageManager(p) + if err != nil { + return err + } + raw, err := p.Read("package.json") + if err != nil { + return err + } + newRoot := raw == nil + if newRoot { + name := filepath.Base(p.Root) + if m.Workspace != nil { + name = m.Workspace.Name + } + raw, err = json.MarshalIndent(buildPackageJSON(name), "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + } + var pkg map[string]json.RawMessage + if err := json.Unmarshal(raw, &pkg); err != nil { + return err + } + if pkg == nil { + return fmt.Errorf("root package.json must be an object") + } + changed := false + if _, ok := pkg["private"]; !ok { + pkg["private"] = json.RawMessage("true") + changed = true + } + if newRoot && manager != "pnpm" { + delete(pkg, "packageManager") + changed = true + } + if manager == "pnpm" { + if _, ok := pkg["packageManager"]; !ok { + pkg["packageManager"], _ = json.Marshal(packageManagerSpec) + changed = true + } + if err := planPNPMWorkspace(p, dirs); err != nil { + return err + } + } else { + var patterns []string + var object map[string]json.RawMessage + if b := pkg["workspaces"]; len(b) > 0 { + if err := json.Unmarshal(b, &patterns); err != nil { + if err := json.Unmarshal(b, &object); err != nil || object == nil { + return fmt.Errorf("invalid package.json workspaces") + } + if err := json.Unmarshal(object["packages"], &patterns); err != nil { + return fmt.Errorf("invalid package.json workspaces.packages: %w", err) + } + } + } + updated, err := includeProjects(patterns, dirs) + if err != nil { + return err + } + if len(updated) != len(patterns) { + b, _ := json.Marshal(updated) + if object != nil { + object["packages"] = b + b, _ = json.Marshal(object) + } + pkg["workspaces"], changed = b, true + } + } + if changed { + raw, err = json.MarshalIndent(pkg, "", " ") + raw = append(raw, '\n') + } + if err != nil { + return err + } + return p.Set("package.json", raw, 0o644) +} + +func includeProjects(patterns, dirs []string) ([]string, error) { + out := append([]string(nil), patterns...) + for _, dir := range dirs { + covered := false + for _, pattern := range patterns { + negative := strings.HasPrefix(pattern, "!") + glob := strings.TrimPrefix(strings.TrimPrefix(pattern, "!"), "./") + match, err := path.Match(glob, dir) + if negative && (err != nil || strings.ContainsAny(glob, "{}()") || strings.Contains(glob, "**")) { + return nil, fmt.Errorf("cannot safely add %s with workspace exclusion %q; update that pattern first", dir, pattern) + } + if match && negative { + return nil, fmt.Errorf("project %s is excluded by workspace pattern %q", dir, pattern) + } + covered = covered || match + } + if !covered { + out = append(out, dir) + } + } + return out, nil +} + +func planPNPMWorkspace(p *fsutil.FilePlan, dirs []string) error { + raw, err := p.Read(WorkspaceFilename) + if err != nil { + return err + } + newFile := raw == nil + if newFile { + raw = []byte(pnpmWorkspaceContent) + } + var doc yaml.Node + if err := yaml.Unmarshal(raw, &doc); err != nil { + return err + } + if len(doc.Content) != 1 || doc.Content[0].Kind != yaml.MappingNode { + return fmt.Errorf("pnpm-workspace.yaml must contain a mapping") + } + root := doc.Content[0] + var packages *yaml.Node + for i := 0; i < len(root.Content); i += 2 { + if root.Content[i].Value == "packages" { + if packages != nil { + return fmt.Errorf("duplicate packages key in pnpm-workspace.yaml") + } + packages = root.Content[i+1] + } + } + if packages == nil { + packages = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + root.Content = append(root.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "packages"}, packages) + } + var patterns []string + if packages.Kind != yaml.SequenceNode { + return fmt.Errorf("pnpm-workspace.yaml packages must be an explicit sequence") + } + if err := packages.Decode(&patterns); err != nil { + return err + } + updated, err := includeProjects(patterns, dirs) + if err != nil { + return err + } + if len(updated) != len(patterns) { + for _, dir := range updated[len(patterns):] { + packages.Content = append(packages.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: dir}) + } + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&doc); err != nil { + return err + } + raw = buf.Bytes() + } else if !newFile { + return nil + } + return p.Set(WorkspaceFilename, raw, 0o644) +} diff --git a/packages/cli/internal/modules/creation/languages_test.go b/packages/cli/internal/modules/creation/languages_test.go new file mode 100644 index 0000000..f7cb11b --- /dev/null +++ b/packages/cli/internal/modules/creation/languages_test.go @@ -0,0 +1,199 @@ +package creation + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" + "golang.org/x/mod/modfile" +) + +func addLanguageProject(t *testing.T, s *Service, root, id, name string) error { + t.Helper() + r, err := template.Fetch(context.Background(), "") + if err != nil { + t.Fatal(err) + } + for i := range r.Templates { + if r.Templates[i].ID == id { + _, err := s.AddProject(context.Background(), root, ProjectInput{Template: &r.Templates[i], Name: name, DeferDeployment: true}) + return err + } + } + t.Fatalf("missing template %s", id) + return nil +} + +func TestFirstProjectInitializesLanguageWorkspaceInEitherOrder(t *testing.T) { + for _, first := range []string{"go", "node"} { + t.Run(first, func(t *testing.T) { + s := newCreationService(t) + root := filepath.Join(t.TempDir(), "workspace with spaces") + if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { + t.Fatal(err) + } + id, name, secondID, secondName := "go-api", "api", "react-spa", "web" + if first == "node" { + id, name, secondID, secondName = secondID, secondName, id, name + } + if err := addLanguageProject(t, s, root, id, name); err != nil { + t.Fatal(err) + } + absent := "package.json" + if first == "node" { + absent = "go.work" + } + if _, err := os.Stat(filepath.Join(root, absent)); !os.IsNotExist(err) { + t.Fatalf("first %s project created %s", first, absent) + } + if first == "go" { + b, _ := os.ReadFile(filepath.Join(root, "go.work")) + if !strings.Contains(string(b), "./services/api") { + t.Fatalf("first module not registered: %s", b) + } + for _, path := range []string{".husky", ".changeset", "pnpm-workspace.yaml", "go.work.sum"} { + if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { + t.Fatalf("unexpected %s", path) + } + } + } + if err := addLanguageProject(t, s, root, secondID, secondName); err != nil { + t.Fatal(err) + } + if err := addLanguageProject(t, s, root, "go-lib", "shared"); err != nil { + t.Fatal(err) + } + pkg, _ := os.ReadFile(filepath.Join(root, "apps/web/package.json")) + if !strings.Contains(string(pkg), `"name": "web"`) { + t.Fatalf("project kept template package name: %s", pkg) + } + b, _ := os.ReadFile(filepath.Join(root, "go.work")) + f, err := modfile.ParseWork("go.work", b, nil) + if err != nil || len(f.Use) != 2 { + t.Fatalf("members: %s %v", b, err) + } + m, err := workspace.ReadManifest(root) + if err != nil { + t.Fatal(err) + } + if len(m.Projects) != 3 { + t.Fatalf("projects = %v", m.Projects) + } + plan := fsutil.NewFilePlan(root) + if err := planLanguages(plan, m); err != nil { + t.Fatal(err) + } + for path, after := range plan.Overlay() { + before, _ := os.ReadFile(filepath.Join(root, path)) + if string(before) != string(after) { + t.Errorf("not idempotent: %s", path) + } + } + rootMise, _ := os.ReadFile(filepath.Join(root, workspace.MiseConfigFilename)) + if strings.Contains(string(rootMise), "go =") || !strings.Contains(string(rootMise), "pnpm =") { + t.Fatalf("wrong root tools: %s", rootMise) + } + hk, err := os.ReadFile(filepath.Join(root, workspace.HooksConfigFilename)) + if err != nil { + t.Fatal(err) + } + for _, step := range []string{`["api:format"]`, `["shared:format"]`, `["web:lint"]`, `["web:format"]`} { + if !strings.Contains(string(hk), step) { + t.Errorf("missing hk step %s", step) + } + } + rootPackage, err := os.ReadFile(filepath.Join(root, "package.json")) + if err != nil { + t.Fatal(err) + } + for _, obsolete := range []string{"husky", "commitlint", "changeset"} { + if strings.Contains(string(rootPackage), obsolete) { + t.Errorf("obsolete workspace tooling %s", obsolete) + } + } + for _, obsolete := range []string{".husky", "commitlint.config.js", ".changeset"} { + if _, err := os.Stat(filepath.Join(root, obsolete)); !os.IsNotExist(err) { + t.Errorf("obsolete workspace file %s", obsolete) + } + } + goMise, _ := os.ReadFile(filepath.Join(root, "services/api", workspace.MiseConfigFilename)) + if !strings.Contains(string(goMise), "go = '1.27.0'") { + t.Fatalf("Go tool not configured: %s", goMise) + } + }) + } +} + +func TestAddConflictLeavesManifestAndRootConfigurationsUnchanged(t *testing.T) { + for _, conflict := range []struct{ file, content, template string }{ + {"go.work", "invalid Go syntax", "go-api"}, + {"pnpm-workspace.yaml", "packages: [ '!apps/*' ]\n", "react-spa"}, + {workspace.MiseConfigFilename, "# user's modified configuration", "go-api"}, + {workspace.HooksConfigFilename, "// user's modified configuration", "go-api"}, + } { + t.Run(conflict.file, func(t *testing.T) { + s := newCreationService(t) + root := filepath.Join(t.TempDir(), "demo") + if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, conflict.file), []byte(conflict.content), 0o644); err != nil { + t.Fatal(err) + } + manifest, _ := os.ReadFile(filepath.Join(root, workspace.ManifestFilename)) + if err := addLanguageProject(t, s, root, conflict.template, "new-project"); err == nil { + t.Fatal("expected conflict") + } + after, _ := os.ReadFile(filepath.Join(root, workspace.ManifestFilename)) + if string(after) != string(manifest) { + t.Fatal("failed add changed manifest") + } + b, _ := os.ReadFile(filepath.Join(root, conflict.file)) + if string(b) != conflict.content { + t.Fatal("failed add overwrote user file") + } + for _, path := range []string{"services/new-project", "apps/new-project", "package.json"} { + if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { + t.Fatalf("failed add left %s", path) + } + } + }) + } +} + +func TestExistingNodeManagerAndUserFilesArePreserved(t *testing.T) { + s := newCreationService(t) + root := filepath.Join(t.TempDir(), "demo") + if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { + t.Fatal(err) + } + user := `{"name":"custom","packageManager":"npm@11.0.0","scripts":{"custom":"echo keep"},"workspaces":["custom/*"]}` + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(user), 0o644); err != nil { + t.Fatal(err) + } + if err := addLanguageProject(t, s, root, "react-spa", "web"); err != nil { + t.Fatal(err) + } + pkg, _ := os.ReadFile(filepath.Join(root, "package.json")) + for _, keep := range []string{"npm@11.0.0", "echo keep", "custom/*", "apps/web"} { + if !strings.Contains(string(pkg), keep) { + t.Fatalf("lost %s: %s", keep, pkg) + } + } + if _, err := os.Stat(filepath.Join(root, "pnpm-workspace.yaml")); !os.IsNotExist(err) { + t.Fatal("npm workspace converted to pnpm") + } + m, _ := workspace.ReadManifest(root) + if m.Projects[0].PackageManager != "npm" || workspace.ProjectDev(m, "web") != "npm run dev" { + t.Fatalf("wrong runtime: %+v", m.Projects[0]) + } + projectPkg, _ := os.ReadFile(filepath.Join(root, "apps/web/package.json")) + if strings.Contains(string(projectPkg), "pnpm") || !strings.Contains(string(projectPkg), "npm@11.0.0") { + t.Fatalf("generated project uses another manager: %s", projectPkg) + } +} diff --git a/packages/cli/internal/modules/creation/project.go b/packages/cli/internal/modules/creation/project.go index 2c4cfce..da102be 100644 --- a/packages/cli/internal/modules/creation/project.go +++ b/packages/cli/internal/modules/creation/project.go @@ -10,7 +10,10 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/hooks" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/miseconfig" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" "github.com/torchstellar-team/one-cli/packages/cli/pkg/toolchain" ) @@ -70,6 +73,30 @@ type ProjectResult struct { // created itself (mirrors cbb95a1's guard) — never touches a // pre-existing tree. func materializeProject(ctx context.Context, projectRoot string, in ProjectInput) (ProjectResult, error) { + projectRoot, err := filepath.EvalSymlinks(projectRoot) + if err != nil { + return ProjectResult{}, err + } + unlock, err := fsutil.WorkspaceLock(ctx, projectRoot, "creation") + if err != nil { + return ProjectResult{}, err + } + defer unlock() + // Share the mise writer's lock while publishing projected configuration. + unlockMise, err := fsutil.WorkspaceLock(ctx, projectRoot, "mise") + if err != nil { + return ProjectResult{}, err + } + defer unlockMise() + miseEnabled := miseconfig.Enabled(projectRoot) + files := fsutil.NewFilePlan(projectRoot) + if _, err := files.Read(workspace.ManifestFilename); err != nil { + return ProjectResult{}, err + } + manifest, err := workspace.ReadManifest(projectRoot) + if err != nil { + return ProjectResult{}, err + } if in.Template == nil { return ProjectResult{}, fmt.Errorf("creation: template is required") } @@ -84,6 +111,9 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput return ProjectResult{}, err } targetDir := filepath.Join(projectRoot, categoryDir, in.Name) + if err := fsutil.SafeWritePath(projectRoot, targetDir); err != nil { + return ProjectResult{}, err + } _, statErr := os.Stat(targetDir) createdFromScratch := os.IsNotExist(statErr) @@ -103,6 +133,12 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput } packageManager := defaultPackageManagerFor(string(entry.Toolchain)) + if entry.Toolchain == "node" { + packageManager, err = nodePackageManager(files) + if err != nil { + return ProjectResult{}, err + } + } vars := template.CommonVariables(in.Name, packageManager) if err := template.Render(templateLocalID, targetDir, vars); err != nil { @@ -111,21 +147,84 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput } return ProjectResult{}, err } + registered := false + defer func() { + if !registered && createdFromScratch { + _ = os.RemoveAll(targetDir) + } + }() relDir, err := filepath.Rel(projectRoot, targetDir) if err != nil { relDir = filepath.Join(categoryDir, in.Name) } manifestPM := manifestPackageManagerFor(string(entry.Toolchain), packageManager) - if err := workspace.UpsertManifestProject(projectRoot, workspace.ManifestProjectInput{ + newProject := workspace.ManifestProject{ Name: in.Name, - RelativeDir: relDir, + RelativeDir: filepath.ToSlash(relDir), TemplateID: entry.ID, Toolchain: string(entry.Toolchain), PackageManager: manifestPM, - }); err != nil { + } + scripts, err := loadProjectScripts(targetDir) + if err != nil { + return ProjectResult{}, err + } + dev := workspace.ResolveScaffoldDevCommand(scripts, string(entry.Toolchain), targetDir) + if entry.Toolchain == "node" && packageManager != "pnpm" { + dev = strings.Replace(dev, "pnpm run ", packageManager+" run ", 1) + } + if dev != "" { + newProject.Domains = &workspace.ProjectDomains{Dev: &workspace.ProjectDevOverride{Command: dev}} + } + for _, p := range manifest.Projects { + if p.RelativeDir == newProject.RelativeDir || p.Name == in.Name { + return ProjectResult{}, fmt.Errorf("project %s is already registered", in.Name) + } + } + manifest.Projects = append(manifest.Projects, newProject) + if err := planLanguages(files, manifest); err != nil { + return ProjectResult{}, err + } + if entry.Toolchain == "node" { + if err := configureNodePackage(files, relDir, vars["projectNameKebabCase"], packageManager); err != nil { + return ProjectResult{}, err + } + } + if hk, err := files.Read(workspace.HooksConfigFilename); err != nil { + return ProjectResult{}, err + } else if hk != nil { + if err := hooks.PlanFiles(files, manifest); err != nil { + return ProjectResult{}, err + } + } + manifestRaw, err := workspace.MarshalManifest(manifest) + if err != nil { return ProjectResult{}, err } + if err := files.Set(workspace.ManifestFilename, manifestRaw, 0o644); err != nil { + return ProjectResult{}, err + } + if miseEnabled { + plan, err := miseconfig.BuildWithFiles(projectRoot, miseconfig.Options{}, files.Overlay()) + if err != nil { + return ProjectResult{}, err + } + for path, content := range plan.ReadInputs() { + if err := files.Expect(path, content); err != nil { + return ProjectResult{}, err + } + } + for _, change := range plan.Changes { + if err := files.Set(change.Path, []byte(change.After), 0o644); err != nil { + return ProjectResult{}, err + } + } + } + if err := files.Apply(ctx); err != nil { + return ProjectResult{}, err + } + registered = true deployBackend := "" if !in.DeferDeployment { @@ -186,8 +285,6 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput compatSelection := workspace.SelectionForProject(compatManifest, nil) warnings := template.CheckAllowedBackends(*entry, compatSelection, "") - _ = ctx // ctx is currently unused; reserved for future cancellation hooks. - return ProjectResult{ Name: in.Name, TargetPath: targetDir, diff --git a/packages/cli/internal/modules/creation/service.go b/packages/cli/internal/modules/creation/service.go index 317869d..9edda2f 100644 --- a/packages/cli/internal/modules/creation/service.go +++ b/packages/cli/internal/modules/creation/service.go @@ -13,8 +13,11 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/hooks" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/miseconfig" "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/preset" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" ) type Service struct { @@ -60,6 +63,7 @@ type WorkspaceResult struct { InfisicalBound bool EnvironmentWarn error RegistryWarn error + HooksWarn error Preset PresetResult PartialState string } @@ -105,7 +109,7 @@ func validateWorkspaceTarget(targetDir, displayPath string) error { func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (WorkspaceResult, error) { result := WorkspaceResult{ Name: input.Name, TargetDir: input.TargetDir, CreatedInPlace: input.CreatedInPlace, - PackageManager: "pnpm", EnvBackend: strings.TrimSpace(input.EnvBackend), PartialState: "none", + EnvBackend: strings.TrimSpace(input.EnvBackend), PartialState: "none", } if !workspace.IsValidProjectName(input.Name) { return result, cliErrors.New( @@ -133,7 +137,7 @@ func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (Wo _, statErr := os.Stat(input.TargetDir) createdFromScratch := os.IsNotExist(statErr) if err := generateWorkspaceFiles(input.TargetDir, workspaceFilesOptions{ - ProjectName: input.Name, PackageManager: result.PackageManager, + ProjectName: input.Name, }); err != nil { if createdFromScratch && !input.CreatedInPlace { _ = os.RemoveAll(input.TargetDir) @@ -156,6 +160,17 @@ func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (Wo } result.InfisicalBound = environment.InfisicalBound result.EnvironmentWarn = environment.BindWarning + manifest, err := workspace.ReadManifest(input.TargetDir) + if err != nil { + return result, err + } + files := fsutil.NewFilePlan(input.TargetDir) + if err := hooks.PlanFiles(files, manifest); err != nil { + return result, err + } + if err := files.Apply(ctx); err != nil { + return result, err + } if input.Preset != nil { applied, applyErr := ApplyPreset(ctx, input.TargetDir, *input.Preset, PresetOptions{ @@ -171,7 +186,27 @@ func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (Wo } } - _ = initGitRepo(input.TargetDir) + plan, err := miseconfig.Build(input.TargetDir, miseconfig.Options{}) + if err == nil { + err = plan.Apply(ctx) + } + if err != nil { + return result, cliErrors.New(cliErrors.ONE_CLI_ERROR, + fmt.Sprintf("workspace was created but mise configuration is incomplete; fix the reported error and run one configure mise: %v", err)). + WithContext(map[string]any{"workspace": input.TargetDir, "partial_state": "mise_configuration_incomplete"}) + } + if pkg, err := workspace.ReadPackageJSON(input.TargetDir); err == nil && pkg != nil { + result.PackageManager, _, _ = strings.Cut(pkg.PackageManager, "@") + } + if err := initGitRepo(input.TargetDir); err != nil { + result.HooksWarn = fmt.Errorf("Git initialization failed; initialize Git and run one configure hooks: %w", err) + } else { + install, err := hooks.PlanInstall(ctx, input.TargetDir, "", false) + if err == nil { + err = install.Apply(ctx) + } + result.HooksWarn = err + } if s.observer != nil { result.RegistryWarn = s.observer(ctx, input.TargetDir, "create") } diff --git a/packages/cli/internal/modules/creation/service_test.go b/packages/cli/internal/modules/creation/service_test.go index e69f8d9..982fd77 100644 --- a/packages/cli/internal/modules/creation/service_test.go +++ b/packages/cli/internal/modules/creation/service_test.go @@ -52,7 +52,7 @@ func TestServiceOwnsWorkspaceAndProjectCreation(t *testing.T) { if err != nil { t.Fatal(err) } - if created.TargetDir != target || created.PackageManager != "pnpm" || created.EnvBackend != "dotenv" { + if created.TargetDir != target || created.PackageManager != "" || created.EnvBackend != "dotenv" { t.Fatalf("CreateWorkspace() = %+v", created) } manifest, err := workspace.ReadManifest(target) diff --git a/packages/cli/internal/modules/creation/workspace_content.go b/packages/cli/internal/modules/creation/workspace_content.go index 168e2bd..f7f4c81 100644 --- a/packages/cli/internal/modules/creation/workspace_content.go +++ b/packages/cli/internal/modules/creation/workspace_content.go @@ -41,40 +41,9 @@ coverage .DS_Store ` -const commitlintConfigContent = `module.exports = { - extends: ['@commitlint/config-conventional'] -}; -` - -const huskyPreCommitContent = `#!/usr/bin/env sh -echo "pre-commit hook: add checks for this repository." -` - -const huskyCommitMsgContent = `#!/usr/bin/env sh -npx --no -- commitlint --edit "$1" -` - -// changesetConfig is the .changeset/config.json scaffolders write at -// create time. Encoded as orderedJSON so json.MarshalIndent preserves -// the field order below. -var changesetConfig = orderedJSON{ - {Key: "$schema", Value: "https://unpkg.com/@changesets/config@3.1.1/schema.json"}, - {Key: "changelog", Value: "@changesets/cli/changelog"}, - {Key: "commit", Value: false}, - {Key: "fixed", Value: []any{}}, - {Key: "linked", Value: []any{}}, - {Key: "access", Value: "restricted"}, - {Key: "baseBranch", Value: "main"}, - {Key: "updateInternalDependencies", Value: "patch"}, - {Key: "ignore", Value: []any{}}, -} - // Package manager spec strings shipped in the workspace-root // package.json. -const ( - packageManagerName = "pnpm" - packageManagerSpec = "pnpm@12.3.4" -) +const packageManagerSpec = "pnpm@12.3.4" // buildPackageJSON returns the workspace root package.json. Workspace-scope // One configuration moved into one.manifest.json in v2 — this file is now @@ -86,16 +55,6 @@ func buildPackageJSON(name string) orderedJSON { {Key: "version", Value: "0.0.0"}, {Key: "packageManager", Value: packageManagerSpec}, {Key: "engines", Value: orderedJSON{{Key: "node", Value: "^24.15.0 || >=26.0.0"}}}, - {Key: "scripts", Value: orderedJSON{ - {Key: "prepare", Value: "husky"}, - {Key: "changeset", Value: "changeset"}, - }}, - {Key: "devDependencies", Value: orderedJSON{ - {Key: "@changesets/cli", Value: "latest"}, - {Key: "@commitlint/cli", Value: "latest"}, - {Key: "@commitlint/config-conventional", Value: "latest"}, - {Key: "husky", Value: "latest"}, - }}, } } diff --git a/packages/cli/internal/modules/creation/workspace_files.go b/packages/cli/internal/modules/creation/workspace_files.go index b6a58b2..5afb24a 100644 --- a/packages/cli/internal/modules/creation/workspace_files.go +++ b/packages/cli/internal/modules/creation/workspace_files.go @@ -10,8 +10,7 @@ import ( ) type workspaceFilesOptions struct { - ProjectName string - PackageManager string // "pnpm" + ProjectName string } // Names of bundled files that the workspace ships with. Centralised so a @@ -28,49 +27,17 @@ func generateWorkspaceFiles(targetDir string, opts workspaceFilesOptions) error if err := os.MkdirAll(targetDir, 0o755); err != nil { return err } - for _, sub := range []string{".changeset", ".husky", "apps", "services", "packages"} { + for _, sub := range []string{"apps", "services", "packages"} { if err := os.MkdirAll(filepath.Join(targetDir, sub), 0o755); err != nil { return err } } - - pkg := buildPackageJSON(opts.ProjectName) - if err := writeJSON(filepath.Join(targetDir, "package.json"), pkg); err != nil { - return err - } - if err := writeJSON(filepath.Join(targetDir, ManifestFilename), emptyManifest(opts.ProjectName)); err != nil { return err } - - if err := os.WriteFile(filepath.Join(targetDir, WorkspaceFilename), []byte(pnpmWorkspaceContent), 0o644); err != nil { - return err - } if err := os.WriteFile(filepath.Join(targetDir, ".gitignore"), []byte(gitignoreContent), 0o644); err != nil { return err } - if err := os.WriteFile(filepath.Join(targetDir, "commitlint.config.js"), []byte(commitlintConfigContent), 0o644); err != nil { - return err - } - if err := writeJSON(filepath.Join(targetDir, ".changeset", "config.json"), changesetConfig); err != nil { - return err - } - - huskyPreCommit := filepath.Join(targetDir, ".husky", "pre-commit") - huskyCommitMsg := filepath.Join(targetDir, ".husky", "commit-msg") - if err := os.WriteFile(huskyPreCommit, []byte(huskyPreCommitContent), 0o755); err != nil { - return err - } - if err := os.WriteFile(huskyCommitMsg, []byte(huskyCommitMsgContent), 0o755); err != nil { - return err - } - // Re-chmod explicitly: WriteFile may apply umask which masks 0o755. - if err := os.Chmod(huskyPreCommit, 0o755); err != nil { - return err - } - if err := os.Chmod(huskyCommitMsg, 0o755); err != nil { - return err - } return nil } diff --git a/packages/cli/internal/modules/creation/workspace_files_test.go b/packages/cli/internal/modules/creation/workspace_files_test.go index 8e439e3..0c5df6d 100644 --- a/packages/cli/internal/modules/creation/workspace_files_test.go +++ b/packages/cli/internal/modules/creation/workspace_files_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "os" "path/filepath" - "runtime" "strings" "testing" @@ -20,79 +19,20 @@ func TestGenerateWorkspaceFiles(t *testing.T) { target := filepath.Join(tmp, "demo") err := generateWorkspaceFiles(target, workspaceFilesOptions{ - ProjectName: "demo", - PackageManager: "pnpm", + ProjectName: "demo", }) if err != nil { t.Fatalf("generateWorkspaceFiles() = %v", err) } - // Files we expect to exist after a full create with infra enabled. - wantFiles := []string{ - "package.json", - "one.manifest.json", - "pnpm-workspace.yaml", - ".gitignore", - "commitlint.config.js", - ".changeset/config.json", - ".husky/pre-commit", - ".husky/commit-msg", - } - for _, rel := range wantFiles { - path := filepath.Join(target, rel) - if _, err := os.Stat(path); err != nil { - t.Errorf("missing file %s: %v", rel, err) + for _, rel := range []string{"one.manifest.json", ".gitignore", "apps", "services", "packages"} { + if _, err := os.Stat(filepath.Join(target, rel)); err != nil { + t.Fatalf("missing %s: %v", rel, err) } } - - // Windows uses ACLs and does not expose Unix executable bits. - if runtime.GOOS != "windows" { - for _, rel := range []string{".husky/pre-commit", ".husky/commit-msg"} { - path := filepath.Join(target, rel) - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat %s: %v", rel, err) - } - mode := info.Mode().Perm() - if mode&0o111 == 0 { - t.Errorf("%s is not executable; mode=%v", rel, mode) - } - } - } - - // package.json field order must match what buildPackageJSON - // stamps. As of manifest v2 there is NO `"one"` key — workspace - // configuration moved to one.manifest.json. - pkgRaw, err := os.ReadFile(filepath.Join(target, "package.json")) - if err != nil { - t.Fatal(err) - } - pkg := string(pkgRaw) - wantOrder := []string{`"name"`, `"private"`, `"version"`, `"packageManager"`, `"scripts"`, `"devDependencies"`} - prev := -1 - for _, key := range wantOrder { - idx := strings.Index(pkg, key) - if idx < 0 { - t.Errorf("package.json missing key %s", key) - continue - } - if idx <= prev { - t.Errorf("package.json key order wrong: %s appears before its predecessor", key) - } - prev = idx - } - if strings.Contains(pkg, `"one"`) { - t.Errorf("package.json should not carry an `one` block in manifest v2") - } - - // JSON files must end with a newline (fs-extra parity). - for _, rel := range []string{"package.json", ".changeset/config.json", "one.manifest.json"} { - raw, err := os.ReadFile(filepath.Join(target, rel)) - if err != nil { - t.Fatal(err) - } - if len(raw) == 0 || raw[len(raw)-1] != '\n' { - t.Errorf("%s missing trailing newline (fs-extra emits one)", rel) + for _, rel := range []string{"package.json", "pnpm-workspace.yaml", "go.work", ".husky", ".changeset", "commitlint.config.js"} { + if _, err := os.Stat(filepath.Join(target, rel)); !os.IsNotExist(err) { + t.Fatalf("empty workspace should not create %s", rel) } } diff --git a/packages/cli/internal/modules/dependencies/service.go b/packages/cli/internal/modules/dependencies/service.go new file mode 100644 index 0000000..6fa8b6f --- /dev/null +++ b/packages/cli/internal/modules/dependencies/service.go @@ -0,0 +1,398 @@ +// Package dependencies prepares application dependencies before development. +// Tool installation belongs to the runtime provider; run remains a plain runner. +package dependencies + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" + "gopkg.in/yaml.v3" +) + +type Runner func(context.Context, runtimeport.Command, io.Writer, io.Writer) error + +type Service struct { + Provider runtimeport.Provider + Run Runner // optional process boundary for tests +} + +type Input struct { + Root string + Manifest *workspace.Manifest + Project string + Runtime string + Log io.Writer +} + +func (s Service) Prepare(ctx context.Context, in Input) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + if in.Log == nil { + in.Log = io.Discard + } + var nodes, goProjects []workspace.ManifestProject + for _, p := range in.Manifest.Projects { + if (in.Project != "" && p.Name != in.Project) || strings.TrimSpace(workspace.ProjectDev(in.Manifest, p.Name)) == "" { + continue + } + switch p.Toolchain { + case "node": + nodes = append(nodes, p) + case "go": + goProjects = append(goProjects, p) + } + } + if len(nodes) > 0 { + if err := s.prepareNode(ctx, in, nodes[0].PackageManager); err != nil { + return err + } + } + for _, p := range goProjects { + if err := ctx.Err(); err != nil { + return err + } + if err := s.prepareGo(ctx, in, p); err != nil { + return err + } + } + return ctx.Err() +} + +func (s Service) run(ctx context.Context, in Input, dir string, args []string, env []string, out, errOut io.Writer) error { + command := runtimeport.Command{Directory: dir, Argv: args, Env: env} + if in.Runtime == runtimeport.Mise { + if s.Provider == nil { + return fmt.Errorf("mise provider is required") + } + var err error + command, err = s.Provider.Prepare(ctx, command) + if err != nil { + return err + } + } + if len(command.Argv) == 0 { + return fmt.Errorf("dependency runtime returned an empty command") + } + run := s.Run + if run == nil { + run = runCommand + } + return run(ctx, command, out, errOut) +} + +func runCommand(ctx context.Context, command runtimeport.Command, out, errOut io.Writer) error { + child := platformprocess.CommandContext(ctx, command.Argv[0], command.Argv[1:]...) + child.Dir, child.Env = command.Directory, command.Env + child.Stdin, child.Stdout, child.Stderr = os.Stdin, out, errOut + platformprocess.CancelProcessTree(child) + err := child.Run() + if ctx.Err() != nil { + return ctx.Err() + } + return err +} + +func (s Service) prepareGo(ctx context.Context, in Input, p workspace.ManifestProject) error { + dir := filepath.Join(in.Root, filepath.FromSlash(p.RelativeDir)) + var work bytes.Buffer + if err := s.run(ctx, in, dir, []string{"go", "env", "GOWORK"}, os.Environ(), &work, in.Log); err != nil { + return preparationError(p.Name, dir, "resolve Go workspace", err, work.String()) + } + active := strings.TrimSpace(work.String()) + lockRoot := dir + if active != "" && active != "off" { + want := filepath.Join(in.Root, "go.work") + if filepath.Clean(active) != filepath.Clean(want) { + return fmt.Errorf("%s uses external GOWORK=%s; use %s or GOWORK=off explicitly", p.Name, active, want) + } + lockRoot = in.Root + } + unlock, err := fsutil.WorkspaceLock(ctx, lockRoot, "go-dependencies") + if err != nil { + return err + } + defer unlock() + fmt.Fprintf(in.Log, "[one] %s: preparing Go dependencies\n", p.Name) + // In workspace mode, package loading uses the actual Go build graph and + // writes workspace sums as needed. Expanding `all` can fetch historical + // versions of local members; only use it for a standalone module. + commands := [][]string{} + if active == "" || active == "off" { + if err := s.downloadModule(ctx, in, p, dir); err != nil { + return err + } + } + commands = append(commands, []string{"go", "list", "-mod=readonly", "-buildvcs=false", "-deps", "./..."}) + for _, args := range commands { + var detail bytes.Buffer + out := io.Discard + + if err := s.run(ctx, in, dir, args, os.Environ(), out, io.MultiWriter(in.Log, &detail)); err != nil { + failure := preparationError(p.Name, dir, strings.Join(args, " "), err, detail.String()) + if args[1] == "list" { + return failure.WithRemediation(output.Remediation{Action: "repair-go-module", Hint: "Inspect the Go error. If module declarations need repair, run tidy explicitly.", Command: "one run " + p.Name + " -- go mod tidy"}) + } + return failure + } + } + return nil +} + +// Go may rewrite go/toolchain directives during mod download. Use an alternate +// module file and publish only checksums after verifying the declarations stayed +// unchanged; never silently repair or upgrade the user's go.mod. +func (s Service) downloadModule(ctx context.Context, in Input, p workspace.ManifestProject, dir string) error { + files := fsutil.NewFilePlan(dir) + module, err := files.Read("go.mod") + if err != nil { + return err + } + if module == nil { + return fmt.Errorf("missing go.mod for %s", p.Name) + } + sums, err := files.Read("go.sum") + if err != nil { + return err + } + f, err := os.CreateTemp(dir, ".one-dependencies-*.mod") + if err != nil { + return err + } + name := f.Name() + sumName := strings.TrimSuffix(name, ".mod") + ".sum" + defer os.Remove(name) + defer os.Remove(sumName) + _, writeErr := f.Write(module) + closeErr := f.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + if sums != nil { + if err := os.WriteFile(sumName, sums, 0o600); err != nil { + return err + } + } + args := []string{"go", "mod", "download", "-modfile=" + name, "all"} + var detail bytes.Buffer + if err := s.run(ctx, in, dir, args, os.Environ(), io.Discard, io.MultiWriter(in.Log, &detail)); err != nil { + return preparationError(p.Name, dir, "go mod download all", err, detail.String()) + } + after, err := os.ReadFile(name) + if err != nil { + return err + } + if !bytes.Equal(module, after) { + return cliErrors.New(cliErrors.ONE_CLI_ERROR, "Go dependencies require changes to "+p.Name+"/go.mod; automatic preparation preserved the original file").WithRemediation(output.Remediation{ + Action: "repair-go-module", Command: "one run " + p.Name + " -- go mod tidy", Hint: "Review the module declarations and run tidy explicitly.", + }) + } + afterSums, err := os.ReadFile(sumName) + if err != nil && !os.IsNotExist(err) { + return err + } + if afterSums != nil { + if err := files.Set("go.sum", afterSums, 0o644); err != nil { + return err + } + return files.Apply(ctx) + } + return nil +} + +func (s Service) prepareNode(ctx context.Context, in Input, fallback string) error { + unlock, err := fsutil.WorkspaceLock(ctx, in.Root, "node-dependencies") + if err != nil { + return err + } + defer unlock() + manager := PackageManager(in.Root, fallback) + var versions bytes.Buffer + for _, args := range [][]string{{manager, "--version"}, {"node", "--version"}} { + if err := s.run(ctx, in, in.Root, args, os.Environ(), &versions, in.Log); err != nil { + return preparationError("Node workspace", in.Root, strings.Join(args, " "), err, versions.String()) + } + } + before, err := nodeFingerprint(in, versions.String()) + if err != nil { + return err + } + cache, err := os.UserCacheDir() + if err != nil { + return err + } + marker := filepath.Join(cache, "one", "dependencies", fmt.Sprintf("%x", sha256.Sum256([]byte(in.Root)))) + previous, _ := os.ReadFile(marker) + if string(previous) == before && nodeInstalled(in) { + return nil + } + args := NodeInstallCommand(in.Root, manager) + fmt.Fprintf(in.Log, "[one] Preparing Node workspace dependencies: %s\n", strings.Join(args, " ")) + var detail bytes.Buffer + if err := s.run(ctx, in, in.Root, args, os.Environ(), in.Log, io.MultiWriter(in.Log, &detail)); err != nil { + return preparationError("Node workspace", in.Root, strings.Join(args, " "), err, detail.String()) + } + after, err := nodeFingerprint(in, versions.String()) + if err != nil { + return err + } + return fsutil.WriteAtomic(marker, []byte(after), 0o600) +} + +func nodeInstalled(in Input) bool { + if !workspace.ProjectDependenciesInstalled(in.Root, in.Root, "node") { + return false + } + for _, p := range in.Manifest.Projects { + if p.Toolchain == "node" && !workspace.ProjectDependenciesInstalled(in.Root, filepath.Join(in.Root, filepath.FromSlash(p.RelativeDir)), "node") { + return false + } + } + return true +} + +func nodeFingerprint(in Input, versions string) (string, error) { + h := sha256.New() + fmt.Fprintln(h, versions) + paths := []string{"package.json", "pnpm-workspace.yaml", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb", ".npmrc", ".yarnrc.yml"} + for _, p := range in.Manifest.Projects { + if p.Toolchain == "node" { + paths = append(paths, filepath.Join(p.RelativeDir, "package.json")) + } + } + for _, path := range paths { + b, err := os.ReadFile(filepath.Join(in.Root, path)) + if err != nil && !os.IsNotExist(err) { + return "", err + } + fmt.Fprintf(h, "%s\x00%d\x00", path, len(b)) + h.Write(b) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func PackageManager(root, fallback string) string { + manager := strings.TrimSpace(fallback) + if pkg, err := workspace.ReadPackageJSON(root); err == nil && pkg != nil && pkg.PackageManager != "" { + manager = pkg.PackageManager + } + manager, _, _ = strings.Cut(manager, "@") + if manager != "" { + return manager + } + for _, item := range []struct{ file, manager string }{{"bun.lock", "bun"}, {"bun.lockb", "bun"}, {"yarn.lock", "yarn"}, {"package-lock.json", "npm"}} { + if exists(filepath.Join(root, item.file)) { + return item.manager + } + } + return "pnpm" +} + +func NodeInstallCommand(root, manager string) []string { + switch manager { + case "pnpm": + if pnpmDependencyLock(filepath.Join(root, "pnpm-lock.yaml")) { + return []string{manager, "install", "--frozen-lockfile"} + } + return []string{manager, "install", "--no-frozen-lockfile"} + case "npm": + if exists(filepath.Join(root, "package-lock.json")) { + return []string{manager, "ci"} + } + case "yarn": + if exists(filepath.Join(root, "yarn.lock")) { + pkg, _ := workspace.ReadPackageJSON(root) + if pkg != nil && strings.HasPrefix(pkg.PackageManager, "yarn@1.") { + return []string{manager, "install", "--frozen-lockfile"} + } + return []string{manager, "install", "--immutable"} + } + case "bun": + if exists(filepath.Join(root, "bun.lock")) || exists(filepath.Join(root, "bun.lockb")) { + return []string{manager, "install", "--frozen-lockfile"} + } + } + return []string{manager, "install"} +} + +// pnpm 12 may write an environment-only YAML document even for --version. +// That records packageManagerDependencies, not installed application packages. +// Read every document; keep malformed/unknown existing locks frozen so pnpm +// reports the problem rather than silently regenerating them. +func pnpmDependencyLock(path string) bool { + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return false + } + if err != nil { + return true + } + decoder := yaml.NewDecoder(bytes.NewReader(b)) + documents := 0 + for { + var doc map[string]any + err := decoder.Decode(&doc) + if err == io.EOF { + return documents == 0 + } + if err != nil { + return true + } + // pnpm separates the not-yet-created application document with a + // trailing `---`; the YAML decoder returns a nil document for it. + if doc == nil { + continue + } + documents++ + importers, ok := doc["importers"].(map[string]any) + if !ok || len(importers) == 0 { + return true + } + for _, value := range importers { + fields, ok := value.(map[string]any) + if !ok || len(fields) == 0 { + return true + } + for key := range fields { + if key != "configDependencies" && key != "packageManagerDependencies" { + return true + } + } + } + } +} + +func exists(path string) bool { _, err := os.Stat(path); return err == nil } + +func preparationError(project, dir, command string, err error, detail string) *output.Error { + code := cliErrors.ONE_CLI_ERROR + var missing *exec.Error + if errors.As(err, &missing) { + code = cliErrors.RUN_COMMAND_NOT_FOUND + } + context := map[string]any{"project": project, "directory": dir, "command": command} + var exit *exec.ExitError + if errors.As(err, &exit) { + context["exit_code"] = exit.ExitCode() + } + return cliErrors.New(code, fmt.Sprintf("%s dependency preparation failed (%s): %v\n%s", project, command, err, strings.TrimSpace(detail))).WithContext(context) +} diff --git a/packages/cli/internal/modules/dependencies/service_test.go b/packages/cli/internal/modules/dependencies/service_test.go new file mode 100644 index 0000000..d4a48c5 --- /dev/null +++ b/packages/cli/internal/modules/dependencies/service_test.go @@ -0,0 +1,290 @@ +package dependencies + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" +) + +func write(t *testing.T, root, path, content string) { + t.Helper() + file := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func project(name, dir, language string) workspace.ManifestProject { + return workspace.ManifestProject{Name: name, RelativeDir: dir, Toolchain: language, Domains: &workspace.ProjectDomains{Dev: &workspace.ProjectDevOverride{Command: "unused-in-preparation"}}} +} + +func setupGo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("go"); err != nil { + t.Skip("Go not installed") + } + t.Setenv("GOTOOLCHAIN", "local") + t.Setenv("GOWORK", "") + t.Setenv("GOFLAGS", "-modcacherw") + t.Setenv("GOSUMDB", "off") + t.Setenv("GOPROXY", "off") + t.Setenv("GOMODCACHE", filepath.Join(t.TempDir(), "modules")) + return t.TempDir() +} + +func TestGoMissingSumsAndEmptyCachePreparedWithoutChangingModule(t *testing.T) { + root := setupGo(t) + proxy := filepath.Join(t.TempDir(), "proxy") + mod := "module example.com/dependency\ngo 1.23.0\n" + write(t, proxy, "example.com/dependency/@v/v1.0.0.mod", mod) + write(t, proxy, "example.com/dependency/@v/v1.0.0.info", `{"Version":"v1.0.0","Time":"2024-01-01T00:00:00Z"}`) + write(t, proxy, "example.com/dependency/@v/list", "v1.0.0\n") + var buf bytes.Buffer + z := zip.NewWriter(&buf) + for name, content := range map[string]string{"go.mod": mod, "dependency.go": "package dependency\nconst Value = 42\n"} { + w, err := z.Create("example.com/dependency@v1.0.0/" + name) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(w, content); err != nil { + t.Fatal(err) + } + } + if err := z.Close(); err != nil { + t.Fatal(err) + } + write(t, proxy, "example.com/dependency/@v/v1.0.0.zip", buf.String()) + urlPath := filepath.ToSlash(proxy) + if !strings.HasPrefix(urlPath, "/") { + urlPath = "/" + urlPath + } + t.Setenv("GOPROXY", "file://"+urlPath) + module := "module example.com/api\ngo 1.23.0\nrequire example.com/dependency v1.0.0\n" + write(t, root, "services/api/go.mod", module) + write(t, root, "services/api/main.go", "package main\nimport (\"fmt\"; \"example.com/dependency\")\nfunc main(){fmt.Println(dependency.Value)}\n") + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "services/api", "go")}}, Runtime: runtimeport.Builtin} + for i := 0; i < 3; i++ { + if i == 2 { + if err := os.RemoveAll(os.Getenv("GOMODCACHE")); err != nil { + t.Fatal(err) + } + } + if err := (Service{}).Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(filepath.Join(root, "services/api/go.mod")) + if string(b) != module { + t.Fatalf("go.mod changed: %s", b) + } + sum, _ := os.ReadFile(filepath.Join(root, "services/api/go.sum")) + if !strings.Contains(string(sum), "example.com/dependency v1.0.0 h1:") { + t.Fatalf("missing content checksum: %s", sum) + } + } +} + +func TestGoWorkspaceUsesLocalLibraryWithNoRemoteVersion(t *testing.T) { + root := setupGo(t) + write(t, root, "go.work", "go 1.25.0\nuse (\n ./services/api\n \"./packages/shared lib\"\n)\n") + write(t, root, "services/api/go.mod", "module example.com/api\ngo 1.25.0\nrequire example.com/shared v0.0.0\n") + write(t, root, "services/api/main.go", "package main\nimport (\"fmt\"; \"example.com/shared\")\nfunc main(){fmt.Println(shared.Value)}\n") + write(t, root, "packages/shared lib/go.mod", "module example.com/shared\ngo 1.25.0\n") + write(t, root, "packages/shared lib/shared.go", "package shared\nconst Value = 42\n") + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "services/api", "go")}}} + if err := (Service{}).Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + write(t, root, "packages/shared lib/shared.go", "package shared\nconst Value = 99\n") + var out bytes.Buffer + if err := (Service{}).run(context.Background(), in, filepath.Join(root, "services/api"), []string{"go", "run", "-buildvcs=false", "."}, os.Environ(), &out, &out); err != nil { + t.Fatalf("%v: %s", err, &out) + } + if strings.TrimSpace(out.String()) != "99" { + t.Fatalf("not using local library: %s", &out) + } +} + +func TestGoUnresolvedImportFailsWithoutTidy(t *testing.T) { + root := setupGo(t) + module := "module example.com/api\ngo 1.25.0\n" + write(t, root, "api/go.mod", module) + write(t, root, "api/main.go", "package main\nimport _ \"example.invalid/missing\"\nfunc main(){}\n") + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "api", "go")}}} + err := (Service{}).Prepare(context.Background(), in) + if err == nil || !strings.Contains(err.Error(), "example.invalid/missing") { + t.Fatalf("error = %v", err) + } + b, _ := os.ReadFile(filepath.Join(root, "api/go.mod")) + if string(b) != module { + t.Fatal("implicit tidy changed go.mod") + } +} + +type fakeProvider struct{ dir string } + +func (p fakeProvider) Prepare(_ context.Context, c runtimeport.Command) (runtimeport.Command, error) { + c.Argv = append([]string{"wrapped"}, c.Argv...) + c.Directory = p.dir + c.Env = append(c.Env, "PREPARED_ENV=present") + return c, nil +} +func (p fakeProvider) PrepareCLI(ctx context.Context, c runtimeport.Command) (runtimeport.Command, error) { + return p.Prepare(ctx, c) +} + +func TestRuntimePreparedDirectoryArgumentsAndEnvironmentAreForwarded(t *testing.T) { + dir := t.TempDir() + s := Service{Provider: fakeProvider{dir}, Run: func(_ context.Context, c runtimeport.Command, _, _ io.Writer) error { + if c.Directory != dir || strings.Join(c.Argv, " ") != "wrapped go version" || !strings.Contains(strings.Join(c.Env, "\n"), "PREPARED_ENV=present") { + t.Fatalf("prepared command lost: %+v", c) + } + return nil + }} + if err := s.run(context.Background(), Input{Runtime: runtimeport.Mise}, "before", []string{"go", "version"}, nil, io.Discard, io.Discard); err != nil { + t.Fatal(err) + } +} + +func TestNodeRootInstallDeduplicatedAndInvalidated(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + root := t.TempDir() + write(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) + write(t, root, "apps/a/package.json", `{"dependencies":{"dependency":"1.0.0"}}`) + write(t, root, "apps/b/package.json", `{}`) + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("a", "apps/a", "node"), project("b", "apps/b", "node")}}} + installs := 0 + s := Service{Run: func(_ context.Context, c runtimeport.Command, out, _ io.Writer) error { + if c.Directory != root { + t.Fatalf("install outside root: %s", c.Directory) + } + if c.Argv[1] == "--version" { + fmt.Fprintln(out, "12.3.4") + return nil + } + installs++ + if err := os.MkdirAll(filepath.Join(root, "node_modules/dependency"), 0o755); err != nil { + return err + } + return nil + }} + for i := 0; i < 2; i++ { + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + } + if installs != 1 { + t.Fatalf("installs=%d", installs) + } + write(t, root, "apps/b/package.json", `{"scripts":{"dev":"updated"}}`) + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + if installs != 2 { + t.Fatal("package change ignored") + } + if err := os.RemoveAll(filepath.Join(root, "node_modules")); err != nil { + t.Fatal(err) + } + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + if installs != 3 { + t.Fatal("cleared dependencies ignored") + } +} + +func TestCancellationAndFailedPreparationPermitRetry(t *testing.T) { + root := t.TempDir() + write(t, root, "api/go.mod", "module example.com/api\ngo 1.25.0\n") + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "api", "go")}}} + started := make(chan struct{}) + var once sync.Once + s := Service{Run: func(ctx context.Context, c runtimeport.Command, out, _ io.Writer) error { + if c.Argv[1] == "env" { + return nil + } + once.Do(func() { close(started) }) + <-ctx.Done() + return ctx.Err() + }} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- s.Prepare(ctx, in) }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("preparation did not start") + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("cancel succeeded") + } + case <-time.After(5 * time.Second): + t.Fatal("cancel blocked") + } + s.Run = func(context.Context, runtimeport.Command, io.Writer, io.Writer) error { return nil } + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatalf("retry could not acquire lock: %v", err) + } +} + +func TestNodeLockfileMismatchUsesFrozenInstallAndStops(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + root := t.TempDir() + write(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) + write(t, root, "pnpm-lock.yaml", "existing lock\n") + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("web", "apps/web", "node"), project("api", "services/api", "go")}}} + s := Service{Run: func(_ context.Context, c runtimeport.Command, out, _ io.Writer) error { + if c.Argv[0] == "go" { + t.Fatal("continued after failed install") + } + if c.Argv[1] == "--version" { + fmt.Fprintln(out, "12.3.4") + return nil + } + if strings.Join(c.Argv, " ") != "pnpm install --frozen-lockfile" { + t.Fatalf("command=%v", c.Argv) + } + return errors.New("lockfile mismatch") + }} + if err := s.Prepare(context.Background(), in); err == nil { + t.Fatal("expected install failure") + } +} + +func TestPNPMEnvironmentDocumentDoesNotPretendDependenciesAreLocked(t *testing.T) { + root := t.TempDir() + environment := "---\nlockfileVersion: '9.0'\nimporters:\n .:\n configDependencies: {}\n packageManagerDependencies:\n pnpm: {specifier: 12.3.4, version: 12.3.4}\n" + graph := "---\nlockfileVersion: '9.0'\nimporters:\n .: {}\n apps/web:\n dependencies:\n react: {specifier: '19', version: '19.3.0'}\n" + for _, fixture := range []struct{ body, flag string }{ + {environment, "--no-frozen-lockfile"}, + {environment + "\n---\n", "--no-frozen-lockfile"}, + {environment + graph, "--frozen-lockfile"}, + {graph + environment, "--frozen-lockfile"}, + {"not valid: [", "--frozen-lockfile"}, + } { + write(t, root, "pnpm-lock.yaml", fixture.body) + args := NodeInstallCommand(root, "pnpm") + if args[len(args)-1] != fixture.flag { + t.Fatalf("lock=%s args=%v", fixture.body, args) + } + } +} diff --git a/packages/cli/internal/modules/development/process/ops.go b/packages/cli/internal/modules/development/process/ops.go index 9a8498f..8af3119 100644 --- a/packages/cli/internal/modules/development/process/ops.go +++ b/packages/cli/internal/modules/development/process/ops.go @@ -17,13 +17,17 @@ import ( "context" "fmt" "os" + "runtime" + "strings" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" ) // StartInput addresses Start. type StartInput struct { + Runtime string ProjectRoot string DryRun bool // Process, when non-empty, restricts the supervisor to a single @@ -33,8 +37,9 @@ type StartInput struct { // StartResult is the Start envelope. type StartResult struct { - Schema string `json:"schema"` - Argv []string `json:"argv"` + Runtime string `json:"runtime,omitempty"` + Schema string `json:"schema"` + Argv []string `json:"argv"` // Runner is always "builtin" now — kept for forward-compat with // JSON consumers that switch on it. Runner string `json:"runner"` @@ -48,6 +53,9 @@ type StartResult struct { // synthetic argv for dry-run / JSON envelopes; blocks until the // supervisor exits otherwise. func Start(ctx context.Context, in StartInput) (*StartResult, error) { + if err := runtimeport.Validate(in.Runtime); err != nil { + return nil, err + } m, err := workspace.ReadManifest(in.ProjectRoot) if err != nil { return nil, err @@ -58,6 +66,25 @@ func Start(ctx context.Context, in StartInput) (*StartResult, error) { selectorErrorMessage(m, in.Process)) } + if in.Runtime == runtimeport.Mise { + binary, err := os.Executable() + if err != nil { + return nil, err + } + for i := range entries { + command := workspace.ProjectDev(m, entries[i].Name) + shell := []string{"sh", "-c", command} + if runtime.GOOS == "windows" { + comspec := os.Getenv("ComSpec") + if comspec == "" { + comspec = "cmd.exe" + } + shell = []string{comspec, "/d", "/s", "/c", command} + } + entries[i].Argv = append([]string{binary, "run", "--project", entries[i].Name, "--"}, shell...) + entries[i].Cmd = strings.Join(entries[i].Argv, " ") + } + } argv := []string{""} for _, e := range entries { argv = append(argv, e.Name+"="+e.Cmd) @@ -69,6 +96,9 @@ func Start(ctx context.Context, in StartInput) (*StartResult, error) { DryRun: in.DryRun, Process: in.Process, } + if in.Runtime == runtimeport.Mise { + res.Runtime = runtimeport.Mise + } if in.DryRun { return res, nil } diff --git a/packages/cli/internal/modules/development/process/supervisor.go b/packages/cli/internal/modules/development/process/supervisor.go index be94c63..a5e2caa 100644 --- a/packages/cli/internal/modules/development/process/supervisor.go +++ b/packages/cli/internal/modules/development/process/supervisor.go @@ -20,6 +20,8 @@ import ( // (ops.go) build the slice directly from the manifest — there is no // longer any text-file parsing step. type ProcEntry struct { + // Argv bypasses shell parsing for runtime wrappers when present. + Argv []string // Name is the workload identifier ("api", "web", ...). Sourced // from manifest.projects[].name. Name string diff --git a/packages/cli/internal/modules/development/process/supervisor_unix.go b/packages/cli/internal/modules/development/process/supervisor_unix.go index 458f946..edfc8f3 100644 --- a/packages/cli/internal/modules/development/process/supervisor_unix.go +++ b/packages/cli/internal/modules/development/process/supervisor_unix.go @@ -88,6 +88,9 @@ func runBuiltin(ctx context.Context, projectRoot string, entries []ProcEntry, op // already started. for _, e := range entries { cmd := exec.Command("sh", "-c", e.Cmd) + if len(e.Argv) > 0 { + cmd = exec.Command(e.Argv[0], e.Argv[1:]...) + } cmd.Dir = projectRoot cmd.Env = os.Environ() cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} diff --git a/packages/cli/internal/modules/development/process/supervisor_windows.go b/packages/cli/internal/modules/development/process/supervisor_windows.go index 8c170c6..d1c5d5e 100644 --- a/packages/cli/internal/modules/development/process/supervisor_windows.go +++ b/packages/cli/internal/modules/development/process/supervisor_windows.go @@ -63,6 +63,9 @@ func runBuiltin(ctx context.Context, projectRoot string, entries []ProcEntry, op } for _, entry := range entries { cmd := exec.Command(comspec, "/d", "/s", "/c", entry.Cmd) + if len(entry.Argv) > 0 { + cmd = exec.Command(entry.Argv[0], entry.Argv[1:]...) + } cmd.Dir = projectRoot cmd.Env = os.Environ() cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP} diff --git a/packages/cli/internal/modules/gowork/workspace.go b/packages/cli/internal/modules/gowork/workspace.go new file mode 100644 index 0000000..14fa5bb --- /dev/null +++ b/packages/cli/internal/modules/gowork/workspace.go @@ -0,0 +1,162 @@ +// Package gowork coordinates Go workspace membership without invoking Go or +// changing module requirements. It uses Go's modfile parser to retain comments. +package gowork + +import ( + "fmt" + "go/version" + "path/filepath" + "sort" + "strings" + + "golang.org/x/mod/modfile" +) + +type Reader func(string) ([]byte, error) + +type Info struct { + Version string + Modules []string // absolute directories, including user-owned external members +} + +func maximum(a, b string) string { + if version.Compare("go"+a, "go"+b) < 0 { + return b + } + return a +} + +func normalize(v string) string { + if strings.Count(v, ".") == 1 { + return v + ".0" + } + return v +} + +func moduleInfo(path string, read Reader) (*modfile.File, string, error) { + b, err := read(filepath.Join(path, "go.mod")) + if err != nil { + return nil, "", err + } + if b == nil { + return nil, "", fmt.Errorf("missing go.mod in workspace member %s (module moved or removed)", path) + } + f, err := modfile.Parse(filepath.Join(path, "go.mod"), b, nil) + if err != nil { + return nil, "", err + } + if f.Module == nil { + return nil, "", fmt.Errorf("%s/go.mod has no module directive", path) + } + v := "1.18.0" + if f.Go != nil { + v = normalize(f.Go.Version) + } + if f.Toolchain != nil && f.Toolchain.Name != "default" { + v = maximum(v, strings.TrimPrefix(f.Toolchain.Name, "go")) + } + return f, v, nil +} + +func inspect(root string, f *modfile.WorkFile, read Reader) (Info, error) { + info := Info{Version: "1.18.0"} + if f.Go != nil { + info.Version = maximum(info.Version, normalize(f.Go.Version)) + } + if f.Toolchain != nil && f.Toolchain.Name != "default" { + info.Version = maximum(info.Version, strings.TrimPrefix(f.Toolchain.Name, "go")) + } + modules := map[string]string{} + dirs := map[string]bool{} + for _, use := range f.Use { + dir := use.Path + if !filepath.IsAbs(dir) { + dir = filepath.Join(root, filepath.FromSlash(dir)) + } + dir = filepath.Clean(dir) + if dirs[dir] { + return Info{}, fmt.Errorf("duplicate go.work member %s", use.Path) + } + dirs[dir] = true + m, v, err := moduleInfo(dir, read) + if err != nil { + return Info{}, err + } + if previous, ok := modules[m.Module.Mod.Path]; ok { + return Info{}, fmt.Errorf("duplicate module path %s in %s and %s", m.Module.Mod.Path, previous, dir) + } + modules[m.Module.Mod.Path] = dir + info.Modules = append(info.Modules, dir) + info.Version = maximum(info.Version, v) + } + sort.Strings(info.Modules) + return info, nil +} + +// Inspect reads an existing workspace; absent go.work returns empty Info. +func Inspect(root string, read Reader) (Info, error) { + b, err := read(filepath.Join(root, "go.work")) + if err != nil || b == nil { + return Info{}, err + } + f, err := modfile.ParseWork(filepath.Join(root, "go.work"), b, nil) + if err != nil { + return Info{}, err + } + return inspect(root, f, read) +} + +// Build initializes on the first module and merges subsequent use entries. +// It never removes user members, replaces, comments, or toolchain directives. +func Build(root string, members []string, read Reader) ([]byte, Info, error) { + b, err := read(filepath.Join(root, "go.work")) + if err != nil { + return nil, Info{}, err + } + if b == nil && len(members) == 0 { + return nil, Info{}, nil + } + initial := b == nil + if initial { + b = []byte("// Go workspace initialized by One CLI. Add modules with one add.\ngo 1.18.0\n") + } + f, err := modfile.ParseWork(filepath.Join(root, "go.work"), b, nil) + if err != nil { + return nil, Info{}, err + } + existing := map[string]bool{} + for _, u := range f.Use { + path := u.Path + if !filepath.IsAbs(path) { + path = filepath.Join(root, filepath.FromSlash(path)) + } + existing[filepath.Clean(path)] = true + } + changed := initial + sort.Strings(members) + for _, member := range members { + dir := filepath.Join(root, filepath.FromSlash(member)) + if existing[dir] { + continue + } + if err := f.AddUse("./"+filepath.ToSlash(member), ""); err != nil { + return nil, Info{}, err + } + existing[dir], changed = true, true + } + info, err := inspect(root, f, read) + if err != nil { + return nil, Info{}, err + } + if f.Go == nil || version.Compare("go"+f.Go.Version, "go"+info.Version) < 0 { + if err := f.AddGoStmt(info.Version); err != nil { + return nil, Info{}, err + } + changed = true + } + if !changed { + return b, info, nil + } + f.Cleanup() + return modfile.Format(f.Syntax), info, nil +} diff --git a/packages/cli/internal/modules/gowork/workspace_test.go b/packages/cli/internal/modules/gowork/workspace_test.go new file mode 100644 index 0000000..4c0832a --- /dev/null +++ b/packages/cli/internal/modules/gowork/workspace_test.go @@ -0,0 +1,61 @@ +package gowork + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" +) + +func put(t *testing.T, root, rel, b string) { + t.Helper() + path := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(b), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestIncrementalGoWorkspacePreservesUserDirectivesAndExternalMembers(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "workspace") + put(t, root, "services/api/go.mod", "module example.com/api\ngo 1.24.0\n") + put(t, parent, "external/go.mod", "module example.com/external\ngo 1.25.0\n") + put(t, root, "packages/shared lib/go.mod", "module example.com/shared\ngo 1.25.0\n") + put(t, root, "go.work", "// my workspace\ngo 1.24.0\ntoolchain go1.25.6\nuse (\n ./services/api // keep this\n ../external\n)\nreplace example.com/old => ../external\n") + p := fsutil.NewFilePlan(root) + b, info, err := Build(root, []string{"services/api", "packages/shared lib"}, p.Read) + if err != nil { + t.Fatal(err) + } + for _, fragment := range []string{"// my workspace", "// keep this", "toolchain go1.25.6", "../external", "replace example.com/old => ../external", `"./packages/shared lib"`} { + if !strings.Contains(string(b), fragment) { + t.Fatalf("lost %s: %s", fragment, b) + } + } + if info.Version != "1.25.6" || len(info.Modules) != 3 { + t.Fatalf("info = %+v", info) + } + put(t, root, "go.work", string(b)) + second, _, err := Build(root, []string{"services/api", "packages/shared lib"}, fsutil.NewFilePlan(root).Read) + if err != nil || string(second) != string(b) { + t.Fatalf("not idempotent: %s %v", second, err) + } +} + +func TestDuplicateModulesAndRemovedMembersAreDiagnosed(t *testing.T) { + root := t.TempDir() + put(t, root, "a/go.mod", "module example.com/same\ngo 1.25.0\n") + put(t, root, "b/go.mod", "module example.com/same\ngo 1.25.0\n") + if _, _, err := Build(root, []string{"a", "b"}, fsutil.NewFilePlan(root).Read); err == nil || !strings.Contains(err.Error(), "duplicate module") { + t.Fatalf("error = %v", err) + } + put(t, root, "go.work", "go 1.25.0\nuse ./removed\n") + if _, _, err := Build(root, []string{"a"}, fsutil.NewFilePlan(root).Read); err == nil || !strings.Contains(err.Error(), "missing go.mod") { + t.Fatalf("error = %v", err) + } +} diff --git a/packages/cli/internal/modules/hooks/config.go b/packages/cli/internal/modules/hooks/config.go new file mode 100644 index 0000000..06eef82 --- /dev/null +++ b/packages/cli/internal/modules/hooks/config.go @@ -0,0 +1,157 @@ +// Package hooks owns workspace-wide hk configuration and local Git launchers. +package hooks + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" +) + +const configHeader = "// Managed by One CLI hooks/v1; sha256=" +const userConfig = "// Customize workspace checks here; One refreshes only .config/one/hk.pkl.\namends \".config/one/hk.pkl\"\n" + +// PlanFiles is static and participates in creation's workspace transaction. +// Existing hk.pkl overrides are never rewritten. +func PlanFiles(p *fsutil.FilePlan, m *workspace.Manifest) error { + before, err := p.Read(workspace.HooksConfigFilename) + if err != nil { + return err + } + if before != nil { + if err := validateManaged(workspace.HooksConfigFilename, before, configHeader); err != nil { + return err + } + } + rootConfig, err := p.Read("hk.pkl") + if err != nil { + return err + } + if rootConfig != nil && before == nil && !strings.Contains(string(rootConfig), `amends ".config/one/hk.pkl"`) { + return conflict("hk.pkl", "an existing hk configuration is present; integrate it with .config/one/hk.pkl before configuring One hooks") + } + if rootConfig == nil { + if err := p.Set("hk.pkl", []byte(userConfig), 0o644); err != nil { + return err + } + } + var b strings.Builder + fmt.Fprintf(&b, "amends %s\nimport %s\n\n", pklQuote(schemaURL("Config.pkl")), pklQuote(schemaURL("Builtins.pkl"))) + fmt.Fprintf(&b, "min_hk_version = %s\n\n", pklQuote(workspace.HKVersion)) + b.WriteString("local one = read?(\"env:ONE_BINARY_PATH\") ?? \"one\"\n\n") + b.WriteString("// Shared by local checks, explicit fixes, and the pre-commit hook.\nsteps {\n") + projects := append([]workspace.ManifestProject(nil), m.Projects...) + sort.Slice(projects, func(i, j int) bool { return projects[i].Name < projects[j].Name }) + for _, project := range projects { + dir := filepath.ToSlash(filepath.Clean(project.RelativeDir)) + if !workspace.IsValidProjectName(project.Name) || dir == "." { + return conflict(project.Name, "invalid project name or directory") + } + if err := fsutil.SafeWritePath(p.Root, filepath.Join(p.Root, dir, "hk.pkl")); err != nil { + return err + } + switch project.Toolchain { + case "go": + fmt.Fprintf(&b, " [%s] {\n dir = %s\n glob = \"**/*.go\"\n exclude = List(\"**/vendor/**\")\n check = new Command { argv = List(\"mise\", \"exec\", \"--\", one, \"__hook-gofmt\", \"--\", \"{{files}}\") }\n fix = new Command { argv = List(\"mise\", \"exec\", \"--\", \"gofmt\", \"-w\", \"--\", \"{{files}}\") }\n }\n", pklQuote(project.Name+":format"), pklQuote(dir)) + case "node": + raw, err := p.Read(dir + "/package.json") + if err != nil { + return err + } + var pkg struct { + Scripts map[string]string `json:"scripts"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal(raw, &pkg); err != nil { + return fmt.Errorf("%s/package.json: %w", dir, err) + } + pm := project.PackageManager + if pm == "" { + pm = "pnpm" + } + prefix, err := packageExec(pm) + if err != nil { + return err + } + for _, tool := range []struct{ name, builtin, operation string }{{"oxlint", "ox_lint", "lint"}, {"oxfmt", "oxfmt", "format"}} { + if pkg.Dependencies[tool.name] != "" || pkg.DevDependencies[tool.name] != "" { + fmt.Fprintf(&b, " [%s] = (Builtins.%s) {\n dir = %s\n prefix = %s\n exclude = List(\"**/.mise/**\", \"**/node_modules/**\", \"**/dist/**\", \"**/pnpm-lock.yaml\", \"**/package-lock.json\")\n", pklQuote(project.Name+":"+tool.operation), tool.builtin, pklQuote(dir), pklList(append([]string{"mise", "exec", "--"}, prefix...))) + if tool.name == "oxfmt" { + // hk 2.0 resolves --list-different output against the root, + // while oxfmt reports paths relative to this step's dir. + // Use its ordinary read-only check before fixing instead. + b.WriteString(" check_list_files = null\n") + } + b.WriteString(" }\n") + continue + } + // Imported projects may use other tools. Retain their explicit + // script contract, but never guess that a bare format script is read-only. + script, fix := "lint", "lint:fix" + if tool.operation == "format" { + script, fix = "format:check", "format:fix" + } + if pkg.Scripts[script] == "" { + continue + } + fmt.Fprintf(&b, " [%s] {\n dir = %s\n glob = \"**/*\"\n exclusive = true\n check = new Command { argv = %s }\n", pklQuote(project.Name+":"+tool.operation), pklQuote(dir), pklList([]string{"mise", "exec", "--", pm, "run", script})) + if pkg.Scripts[fix] != "" { + fmt.Fprintf(&b, " fix = new Command { argv = %s }\n", pklList([]string{"mise", "exec", "--", pm, "run", fix})) + } + b.WriteString(" }\n") + } + } + } + b.WriteString("}\n\nhooks {\n [\"pre-commit\"] {\n fix = false\n stage = false\n stash = \"git\"\n }\n [\"commit-msg\"] {\n steps {\n [\"conventional-commit\"] {\n check = new Command { argv = List(\"hk\", \"util\", \"check-conventional-commit\", \"{{commit_msg_file}}\") }\n }\n }\n }\n}\n") + body := []byte(b.String()) + after := []byte(fmt.Sprintf("%s%x\n// Customize hk.pkl; refresh generated defaults with one configure hooks.\n%s", configHeader, sha256.Sum256(body), body)) + return p.Set(workspace.HooksConfigFilename, after, 0o644) +} + +func schemaURL(name string) string { + return "package://github.com/jdx/hk/releases/download/v" + workspace.HKVersion + "/hk@" + workspace.HKVersion + "#/" + name +} + +func pklQuote(value string) string { return strconv.Quote(value) } + +func pklList(values []string) string { + quoted := make([]string, len(values)) + for i, value := range values { + quoted[i] = pklQuote(value) + } + return "List(" + strings.Join(quoted, ", ") + ")" +} + +func packageExec(manager string) ([]string, error) { + switch manager { + case "pnpm", "yarn": + return []string{manager, "exec"}, nil + case "npm": + return []string{"npm", "exec", "--offline", "--"}, nil + case "bun": + return []string{"bun", "run"}, nil + default: + return nil, fmt.Errorf("unsupported hook package manager %q", manager) + } +} + +func validateManaged(path string, raw []byte, header string) error { + lines := bytes.SplitN(raw, []byte("\n"), 3) + if len(lines) != 3 || string(lines[0]) != fmt.Sprintf("%s%x", header, sha256.Sum256(lines[2])) { + return conflict(path, "file is user-owned or has been modified; preserve it and resolve the conflict before regenerating") + } + return nil +} + +func conflict(path, reason string) error { + return cliErrors.New(cliErrors.HOOKS_CONFIG_CONFLICT, fmt.Sprintf("%s: %s", path, reason)).WithContext(map[string]any{"path": path}) +} diff --git a/packages/cli/internal/modules/hooks/hooks_test.go b/packages/cli/internal/modules/hooks/hooks_test.go new file mode 100644 index 0000000..65b660c --- /dev/null +++ b/packages/cli/internal/modules/hooks/hooks_test.go @@ -0,0 +1,306 @@ +package hooks + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" +) + +func fixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + // Git exports repository-local paths when running this suite from a hook. + // Each fixture and its linked worktrees must resolve their own Git state. + for _, key := range []string{"GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"} { + t.Setenv(key, "") // Register restoration of the original environment. + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + } + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + write(t, root, "one.manifest.json", `{"version":1,"workspace":{"id":"hooks","name":"hooks"},"projects":[]}`) + cmd := exec.Command("git", "init", "-q", root) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v %s", err, out) + } + return root +} + +func write(t *testing.T, root, path, value string) { + t.Helper() + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(value), 0o644); err != nil { + t.Fatal(err) + } +} + +func read(t *testing.T, root, path string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestConfigureDryRunIdempotentAndUserOverrides(t *testing.T) { + root := fixture(t) + ctx := context.Background() + binary := filepath.Join(t.TempDir(), "one with space", "one") + preview, err := Configure(ctx, root, binary, true) + if err != nil { + t.Fatal(err) + } + if !preview.DryRun || len(preview.GitChanges) != 2 || len(preview.Changes) != 3 { + t.Fatalf("preview: %+v", preview) + } + for _, path := range []string{"hk.pkl", workspace.HooksConfigFilename, workspace.MiseConfigFilename, ".git/hooks/pre-commit"} { + if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { + t.Fatalf("dry-run wrote %s", path) + } + } + if _, err := Configure(ctx, root, binary, false); err != nil { + t.Fatal(err) + } + if !strings.Contains(read(t, root, workspace.MiseConfigFilename), "aqua:jdx/hk") { + t.Fatal("hk not declared in mise") + } + custom := read(t, root, "hk.pkl") + "\n// Team overrides\nhooks { [\"pre-commit\"] { enabled = false } }\n" + write(t, root, "hk.pkl", custom) + again, err := Configure(ctx, root, binary, false) + if err != nil { + t.Fatal(err) + } + if len(again.Changes) != 0 || len(again.GitChanges) != 0 || read(t, root, "hk.pkl") != custom { + t.Fatalf("not idempotent: %+v", again) + } + if !strings.Contains(read(t, root, ".git/hooks/commit-msg"), shellQuote(filepath.ToSlash(binary))+" hk run commit-msg \"$@\"") { + t.Fatal("missing quoted fallback launcher") + } +} + +func TestConfigureConflictsDoNotPublish(t *testing.T) { + for _, file := range []string{"hk.pkl", ".git/hooks/pre-commit", ".git/hooks/commit-msg", "commitlint.config.js", "commitlint.config.ts", ".commitlintrc.json", ".husky/commit-msg", ".husky/pre-push"} { + t.Run(file, func(t *testing.T) { + root := fixture(t) + write(t, root, file, "user-owned content\n") + if _, err := Configure(context.Background(), root, "", false); err == nil { + t.Fatal("expected a conflict") + } + if read(t, root, file) != "user-owned content\n" { + t.Fatal("user file changed") + } + if _, err := os.Stat(filepath.Join(root, workspace.HooksConfigFilename)); !os.IsNotExist(err) { + t.Fatal("published a partial configuration") + } + }) + } + t.Run("package commitlint rules", func(t *testing.T) { + root := fixture(t) + write(t, root, "package.json", `{"commitlint":{"rules":{"header-max-length":[2,"always",50]}}}`) + if _, err := Configure(context.Background(), root, "", false); err == nil { + t.Fatal("expected conflict") + } + if _, err := os.Stat(filepath.Join(root, "hk.pkl")); !os.IsNotExist(err) { + t.Fatal("published a partial configuration") + } + }) + t.Run("custom hook directory", func(t *testing.T) { + root := fixture(t) + if _, err := gitOutput(context.Background(), root, "config", "--local", "core.hooksPath", ".custom-hooks"); err != nil { + t.Fatal(err) + } + if _, err := Configure(context.Background(), root, "", false); err == nil { + t.Fatal("expected conflict") + } + if value, _ := hooksPath(context.Background(), root); value != ".custom-hooks" { + t.Fatal("custom hooksPath changed") + } + }) +} + +func TestConfigureLinkedWorktreeUsesCommonGitHooks(t *testing.T) { + root := fixture(t) + ctx := context.Background() + for _, args := range [][]string{ + {"add", "one.manifest.json"}, + {"-c", "user.name=One hook test", "-c", "user.email=test@example.invalid", "commit", "-qm", "test fixture"}, + } { + if out, err := gitOutput(ctx, root, args...); err != nil { + t.Fatalf("git %v: %v %s", args, err, out) + } + } + linked := filepath.Join(t.TempDir(), "linked") + if out, err := gitOutput(ctx, root, "worktree", "add", "--detach", linked); err != nil { + t.Fatalf("git worktree: %v %s", err, out) + } + result, err := Configure(ctx, linked, "", false) + if err != nil { + t.Fatal(err) + } + want, err := filepath.EvalSymlinks(filepath.Join(root, ".git")) + if err != nil || result.GitDirectory != want { + t.Fatalf("common Git directory: got %s, want %s, error %v", result.GitDirectory, want, err) + } + if !strings.Contains(read(t, root, ".git/hooks/pre-commit"), "hk run pre-commit") { + t.Fatal("common launcher missing") + } + if info, err := os.Stat(filepath.Join(linked, ".git")); err != nil || info.IsDir() { + t.Fatalf("linked worktree Git file replaced: %v", err) + } + if _, err := os.Stat(filepath.Join(linked, "hk.pkl")); err != nil { + t.Fatal("worktree hk configuration missing") + } +} + +func TestConfigureMigratesOnlyLegacyDefaults(t *testing.T) { + testLegacyMigration(t, fixture(t)) +} + +func TestConfigureMigratesLegacyDefaultsThroughSymlink(t *testing.T) { + root := fixture(t) + alias := filepath.Join(t.TempDir(), "workspace-alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + testLegacyMigration(t, alias) +} + +func testLegacyMigration(t *testing.T, root string) { + t.Helper() + for path, content := range legacyFiles { + write(t, root, path, content) + } + write(t, root, "package.json", `{"name":"keep","private":true,"packageManager":"pnpm@12.3.4","scripts":{"prepare":"husky","other":"keep-me"},"devDependencies":{"husky":"9.0.0","@commitlint/cli":"19.0.0","@commitlint/config-conventional":"19.0.0","@changesets/cli":"2.0.0"},"custom":{"keep":true}}`) + write(t, root, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") + if _, err := gitOutput(context.Background(), root, "config", "--local", "core.hooksPath", ".husky/_"); err != nil { + t.Fatal(err) + } + preview, err := Configure(context.Background(), root, "", true) + if err != nil { + t.Fatal(err) + } + if preview.HooksPath == "" || len(preview.Warnings) == 0 { + t.Fatalf("missing migration details: %+v", preview) + } + if _, err := Configure(context.Background(), root, "", false); err != nil { + t.Fatal(err) + } + for path := range legacyFiles { + if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { + t.Fatalf("legacy file remains: %s", path) + } + } + var pkg map[string]any + if err := json.Unmarshal([]byte(read(t, root, "package.json")), &pkg); err != nil { + t.Fatal(err) + } + if pkg["name"] != "keep" || pkg["custom"] == nil || pkg["scripts"].(map[string]any)["other"] != "keep-me" { + t.Fatal("lost unrelated package settings") + } + deps := pkg["devDependencies"].(map[string]any) + if len(deps) != 1 || deps["@changesets/cli"] == nil { + t.Fatalf("deps: %+v", deps) + } + // Installation resolves the common Git directory, including Windows short + // paths and workspace symlinks. Compare the same canonical directory here. + wantPath, err := filepath.EvalSymlinks(filepath.Join(root, ".git", "hooks")) + if err != nil { + t.Fatal(err) + } + if value, err := hooksPath(context.Background(), root); err != nil || value != filepath.ToSlash(wantPath) { + t.Fatalf("hooksPath = %q, want %q, error %v", value, filepath.ToSlash(wantPath), err) + } + if read(t, root, "pnpm-lock.yaml") != "lockfileVersion: '9.0'\n" { + t.Fatal("rewrote package manager lockfile") + } +} + +func TestGeneratedLanguageStepsAndConcurrentEdit(t *testing.T) { + root := fixture(t) + write(t, root, "apps/web/package.json", `{"devDependencies":{"oxlint":"1.82.0","oxfmt":"0.67.0"}}`) + m := &workspace.Manifest{Projects: []workspace.ManifestProject{ + {Name: "api", RelativeDir: "services/api", Toolchain: "go"}, + {Name: "web", RelativeDir: "apps/web", Toolchain: "node", PackageManager: "npm"}, + }} + p := fsutil.NewFilePlan(root) + if err := PlanFiles(p, m); err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err != nil { + t.Fatal(err) + } + config := read(t, root, workspace.HooksConfigFilename) + for _, part := range []string{`["api:format"]`, `dir = "services/api"`, `__hook-gofmt`, `["web:lint"]`, `Builtins.oxfmt`, `"npm", "exec", "--offline", "--"`, `fix = false`, `stage = false`, `stash = "git"`, `check-conventional-commit`} { + if !strings.Contains(config, part) { + t.Errorf("missing %s", part) + } + } + if strings.Contains(config, "tidy") { + t.Fatal("implicit go mod tidy") + } + p = fsutil.NewFilePlan(root) + if err := PlanFiles(p, m); err != nil { + t.Fatal(err) + } + write(t, root, workspace.HooksConfigFilename, config+"// concurrent edit\n") + if err := p.Apply(context.Background()); err == nil { + t.Fatal("expected concurrent edit conflict") + } + p = fsutil.NewFilePlan(root) + if err := PlanFiles(p, m); err == nil { + t.Fatal("modified generated configuration was accepted") + } +} + +func TestRealHKValidatesGeneratedPkl(t *testing.T) { + binary := os.Getenv("ONE_TEST_HK_BINARY") + if binary == "" { + t.Skip("set ONE_TEST_HK_BINARY for native hk integration") + } + t.Setenv("PATH", filepath.Dir(binary)+string(os.PathListSeparator)+os.Getenv("PATH")) + root := fixture(t) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + write(t, root, "apps/web/package.json", `{"devDependencies":{"oxlint":"1.82.0","oxfmt":"0.67.0"}}`) + p := fsutil.NewFilePlan(root) + m := &workspace.Manifest{Projects: []workspace.ManifestProject{{Name: "api", RelativeDir: "services/api", Toolchain: "go"}, {Name: "web", RelativeDir: "apps/web", Toolchain: "node", PackageManager: "pnpm"}}} + if err := PlanFiles(p, m); err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err != nil { + t.Fatal(err) + } + cmd := exec.Command(binary, "validate") + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("hk validate: %v %s", err, out) + } + for _, test := range []struct { + message string + valid bool + }{{"feat(api): 支持 Go 工作区\n", true}, {"fix!: change API\n\nBREAKING CHANGE: API changed\n", true}, {"fixup! temporary\n", true}, {"not a conventional commit\n", false}, {"unknown: test\n", false}} { + write(t, root, "commit message.txt", test.message) + cmd := exec.Command(binary, "run", "commit-msg", filepath.Join(root, "commit message.txt")) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if (err == nil) != test.valid { + t.Fatalf("message %q: %v %s", test.message, err, out) + } + if !bytes.Equal([]byte(read(t, root, "commit message.txt")), []byte(test.message)) { + t.Fatal("commit message changed") + } + } +} diff --git a/packages/cli/internal/modules/hooks/install.go b/packages/cli/internal/modules/hooks/install.go new file mode 100644 index 0000000..72bef55 --- /dev/null +++ b/packages/cli/internal/modules/hooks/install.go @@ -0,0 +1,143 @@ +package hooks + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" +) + +const launcherHeader = "# Managed by One CLI hooks/v1; sha256=" + +type InstallPlan struct { + Root string + Files *fsutil.FilePlan + previousPath string + nextPath string +} + +// PlanInstall never invokes hk or downloads a tool. Git's machine-local +// launchers enter through One so GUI clients need no activated mise shell. +func PlanInstall(ctx context.Context, root, binary string, migrateHusky bool) (*InstallPlan, error) { + top, err := gitOutput(ctx, root, "rev-parse", "--show-toplevel") + if err != nil { + return nil, fmt.Errorf("initialize Git, then run one configure hooks: %w", err) + } + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + canonicalTop, err := filepath.EvalSymlinks(top) + if err != nil || canonicalRoot != canonicalTop { + return nil, conflict(root, "the One workspace must be the Git repository root") + } + common, err := gitOutput(ctx, root, "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + return nil, err + } + common, err = filepath.EvalSymlinks(common) + if err != nil { + return nil, err + } + configured, err := hooksPath(ctx, root) + if err != nil { + return nil, err + } + defaultPath := filepath.Join(common, "hooks") + if configured != "" { + resolved := configured + if !filepath.IsAbs(resolved) { + resolved = filepath.Join(root, resolved) + } + if filepath.Clean(resolved) != defaultPath && !(migrateHusky && (filepath.ToSlash(configured) == ".husky/_" || filepath.ToSlash(configured) == ".husky")) { + return nil, conflict("core.hooksPath", "existing hook directory "+configured+" is user-managed; preserve and integrate its checks before changing the hook setup") + } + } + if binary == "" { + binary, err = os.Executable() + if err != nil { + return nil, err + } + } + binary, err = filepath.Abs(binary) + if err != nil { + return nil, err + } + files := fsutil.NewFilePlan(common) + for _, hook := range []string{"pre-commit", "commit-msg"} { + path := "hooks/" + hook + before, err := files.Read(path) + if err != nil { + return nil, err + } + if before != nil { + if err := validateManaged(path, []byte(strings.TrimPrefix(string(before), "#!/bin/sh\n")), launcherHeader); err != nil { + return nil, err + } + } + body := "[ -f hk.pkl ] || exit 0\n" + + "if [ -x " + shellQuote(filepath.ToSlash(binary)) + " ]; then\n exec " + shellQuote(filepath.ToSlash(binary)) + " hk run " + hook + " \"$@\"\nfi\n" + + "exec one hk run " + hook + " \"$@\"\n" + content := fmt.Sprintf("#!/bin/sh\n%s%x\n# One supplies mise; no shell activation is needed.\n%s", launcherHeader, sha256.Sum256([]byte(body)), body) + if err := files.Set(path, []byte(content), 0o755); err != nil { + return nil, err + } + } + nextPath := "" + if configured != "" && filepath.Clean(configured) != defaultPath { + // A local absolute path overrides the previous Husky/global setting + // without changing any user-global Git configuration. + nextPath = filepath.ToSlash(defaultPath) + } + return &InstallPlan{Root: root, Files: files, previousPath: configured, nextPath: nextPath}, nil +} + +func (p *InstallPlan) Apply(ctx context.Context) error { + unlock, err := fsutil.WorkspaceLock(ctx, p.Files.Root, "hooks-install") + if err != nil { + return err + } + defer unlock() + current, err := hooksPath(ctx, p.Root) + if err != nil { + return err + } + if current != p.previousPath { + return conflict("core.hooksPath", "changed while configuring hooks; retry") + } + if err := p.Files.Apply(ctx); err != nil { + return err + } + // Repair executable bits without changing user-written hook contents. + for _, hook := range []string{"pre-commit", "commit-msg"} { + if err := os.Chmod(filepath.Join(p.Files.Root, "hooks", hook), 0o755); err != nil { + return err + } + } + if p.nextPath != "" { + _, err = gitOutput(ctx, p.Root, "config", "--local", "core.hooksPath", p.nextPath) + } + return err +} + +func hooksPath(ctx context.Context, root string) (string, error) { + value, err := gitOutput(ctx, root, "config", "--get", "core.hooksPath") + if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() == 1 { + return "", nil + } + return value, err +} + +func gitOutput(ctx context.Context, root string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = root + out, err := cmd.Output() + return strings.TrimSpace(string(out)), err +} + +func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } diff --git a/packages/cli/internal/modules/hooks/legacy.go b/packages/cli/internal/modules/hooks/legacy.go new file mode 100644 index 0000000..9353fdc --- /dev/null +++ b/packages/cli/internal/modules/hooks/legacy.go @@ -0,0 +1,108 @@ +package hooks + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" +) + +// Only One's exact former defaults may be removed automatically. A customized +// commitlint rule or hook is a migration conflict, never disposable boilerplate. +var legacyFiles = map[string]string{ + "commitlint.config.js": "module.exports = {\n extends: ['@commitlint/config-conventional']\n};\n", + ".husky/pre-commit": "#!/usr/bin/env sh\necho \"pre-commit hook: add checks for this repository.\"\n", + ".husky/commit-msg": "#!/usr/bin/env sh\nnpx --no -- commitlint --edit \"$1\"\n", +} + +func planLegacy(p *fsutil.FilePlan) (bool, error) { + legacy := false + rootEntries, err := os.ReadDir(p.Root) + if err != nil { + return false, err + } + for _, entry := range rootEntries { + name := entry.Name() + if name != "commitlint.config.js" && (strings.HasPrefix(name, "commitlint.config.") || strings.HasPrefix(name, ".commitlintrc")) { + return false, conflict(name, "custom commitlint configuration must be integrated into hk.pkl before migration") + } + } + for path, expected := range legacyFiles { + raw, err := p.Read(path) + if err != nil { + return false, err + } + if raw != nil { + if string(raw) != expected { + return false, conflict(path, "custom hook or commitlint rules require migration into hk.pkl; the file has been preserved") + } + legacy = true + } + } + entries, err := os.ReadDir(filepath.Join(p.Root, ".husky")) + if err != nil && !os.IsNotExist(err) { + return false, err + } + for _, entry := range entries { + if entry.Name() != "_" && entry.Name() != "pre-commit" && entry.Name() != "commit-msg" { + return false, conflict(".husky/"+entry.Name(), "custom Husky content must be integrated before migration") + } + } + raw, err := p.Read("package.json") + if err != nil { + return false, err + } + var pkg map[string]json.RawMessage + if raw != nil { + if err := json.Unmarshal(raw, &pkg); err != nil || pkg == nil { + return false, fmt.Errorf("invalid root package.json") + } + if pkg["commitlint"] != nil { + return false, conflict("package.json", "custom commitlint configuration must be integrated into hk.pkl before migration") + } + } + if !legacy { + return false, nil + } + if pkg != nil { + for _, section := range []string{"scripts", "devDependencies", "dependencies"} { + if pkg[section] == nil { + continue + } + var values map[string]json.RawMessage + if err := json.Unmarshal(pkg[section], &values); err != nil { + return false, err + } + if section == "scripts" { + if prepare := values["prepare"]; prepare != nil { + var command string + if json.Unmarshal(prepare, &command) != nil || command != "husky" { + return false, conflict("package.json", "custom prepare script must be migrated explicitly") + } + delete(values, "prepare") + } + } else { + for _, key := range []string{"husky", "@commitlint/cli", "@commitlint/config-conventional"} { + delete(values, key) + } + } + pkg[section], _ = json.Marshal(values) + } + after, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return false, err + } + if err := p.Set("package.json", append(after, '\n'), 0o644); err != nil { + return false, err + } + } + for path := range legacyFiles { + if err := p.Remove(path); err != nil { + return false, err + } + } + return true, nil +} diff --git a/packages/cli/internal/modules/hooks/native_unix_test.go b/packages/cli/internal/modules/hooks/native_unix_test.go new file mode 100644 index 0000000..e5c1cf2 --- /dev/null +++ b/packages/cli/internal/modules/hooks/native_unix_test.go @@ -0,0 +1,83 @@ +//go:build !windows + +package hooks + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" +) + +// Exercise hk itself: its file-list optimization resolves formatter output +// against the workspace root, even when a step runs inside a project directory. +func TestRealHKFixesProjectRelativeFilesWithoutStaging(t *testing.T) { + binary := os.Getenv("ONE_TEST_HK_BINARY") + if binary == "" { + t.Skip("set ONE_TEST_HK_BINARY for native hk integration") + } + root := fixture(t) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + bin := t.TempDir() + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + for name, script := range map[string]string{ + "mise": "shift; shift; exec \"$@\"\n", + "pnpm": "shift; exec \"$@\"\n", + "oxfmt": `mode=$1 +shift; shift +case "$mode" in + --check) [ "$(cat "$1")" = 'formatted' ] ;; + --list-different) printf '%s\n' "$1"; exit 1 ;; + --write) printf 'formatted\n' > "$1" ;; + *) exit 99 ;; +esac +`, + } { + path := filepath.Join(bin, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755); err != nil { + t.Fatal(err) + } + } + write(t, root, "apps/web/package.json", `{"devDependencies":{"oxfmt":"0.67.0"}}`) + file := "apps/web/src/file with spaces.ts" + write(t, root, file, "unformatted\n") + p := fsutil.NewFilePlan(root) + m := &workspace.Manifest{Projects: []workspace.ManifestProject{{Name: "web", RelativeDir: "apps/web", Toolchain: "node", PackageManager: "pnpm"}}} + if err := PlanFiles(p, m); err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := gitOutput(context.Background(), root, "add", "--", file); err != nil { + t.Fatal(err) + } + indexBefore, err := gitOutput(context.Background(), root, "diff", "--cached", "--binary") + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + command string + valid bool + }{{"check", false}, {"fix", true}, {"check", true}} { + cmd := exec.Command(binary, test.command, file) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if (err == nil) != test.valid { + t.Fatalf("hk %s: %v %s", test.command, err, out) + } + indexAfter, err := gitOutput(context.Background(), root, "diff", "--cached", "--binary") + if err != nil || indexAfter != indexBefore { + t.Fatalf("hk %s changed staged content: %v", test.command, err) + } + } + if got := read(t, root, file); got != "formatted\n" { + t.Fatalf("fix did not format the project file: %q", got) + } +} diff --git a/packages/cli/internal/modules/hooks/service.go b/packages/cli/internal/modules/hooks/service.go new file mode 100644 index 0000000..03029ea --- /dev/null +++ b/packages/cli/internal/modules/hooks/service.go @@ -0,0 +1,107 @@ +package hooks + +import ( + "context" + "fmt" + "io" + "path/filepath" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/miseconfig" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" +) + +type Result struct { + Schema string `json:"schema"` + Root string `json:"root"` + DryRun bool `json:"dry_run"` + Changes []fsutil.FileChange `json:"changes"` + GitChanges []fsutil.FileChange `json:"git_changes"` + GitDirectory string `json:"git_directory"` + HooksPath string `json:"hooks_path,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +func (r *Result) RenderTTY(w io.Writer) { + if r.DryRun { + fmt.Fprintln(w, "Proposed hk configuration and local Git hooks:") + } else { + fmt.Fprintln(w, "hk configuration refreshed; Git hooks now run through One.") + } + for _, change := range append(append([]fsutil.FileChange{}, r.Changes...), r.GitChanges...) { + action := "write" + if change.Remove { + action = "remove" + } + fmt.Fprintf(w, " %s %s\n", action, change.Path) + } + for _, warning := range r.Warnings { + fmt.Fprintf(w, " %s\n", warning) + } + fmt.Fprintln(w, "Check: one hk check --all\nFix: one hk fix") +} + +// Configure validates the complete migration before publishing any files. It +// neither downloads tools nor installs application dependencies. +func Configure(ctx context.Context, root, binary string, dryRun bool) (*Result, error) { + root, err := filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + unlock, err := fsutil.WorkspaceLock(ctx, root, "creation") + if err != nil { + return nil, err + } + defer unlock() + unlockMise, err := fsutil.WorkspaceLock(ctx, root, "mise") + if err != nil { + return nil, err + } + defer unlockMise() + p := fsutil.NewFilePlan(root) + if _, err := p.Read(workspace.ManifestFilename); err != nil { + return nil, err + } + m, err := workspace.ReadManifest(root) + if err != nil { + return nil, err + } + legacy, err := planLegacy(p) + if err != nil { + return nil, err + } + if err := PlanFiles(p, m); err != nil { + return nil, err + } + mise, err := miseconfig.BuildWithFiles(root, miseconfig.Options{}, p.Overlay()) + if err != nil { + return nil, err + } + for path, b := range mise.ReadInputs() { + if err := p.Expect(path, b); err != nil { + return nil, err + } + } + for _, c := range mise.Changes { + if err := p.Set(c.Path, []byte(c.After), 0o644); err != nil { + return nil, err + } + } + install, err := PlanInstall(ctx, root, binary, legacy) + if err != nil { + return nil, err + } + result := &Result{Schema: "one-cli/hooks-config/v1", Root: root, DryRun: dryRun, Changes: p.Changes(), GitChanges: install.Files.Changes(), GitDirectory: install.Files.Root, HooksPath: install.nextPath} + if legacy { + result.Warnings = []string{"Husky/commitlint defaults were migrated. Run your package manager's install through one mise exec to refresh the dependency lockfile."} + } + if !dryRun { + if err := p.Apply(ctx); err != nil { + return nil, err + } + if err := install.Apply(ctx); err != nil { + return nil, fmt.Errorf("hk configuration was written but Git hook installation failed; resolve the conflict and rerun one configure hooks: %w", err) + } + } + return result, nil +} diff --git a/packages/cli/internal/modules/miseconfig/config.go b/packages/cli/internal/modules/miseconfig/config.go new file mode 100644 index 0000000..5a3fca3 --- /dev/null +++ b/packages/cli/internal/modules/miseconfig/config.go @@ -0,0 +1,493 @@ +// Package miseconfig produces additive mise fragments without rewriting user TOML. +package miseconfig + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/pelletier/go-toml/v2" + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/gowork" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" + "gopkg.in/yaml.v3" +) + +const Filename = workspace.MiseConfigFilename +const header = "# Managed by One CLI mise/v1; sha256=" + +type Options struct { + NodeVersion string + GoVersion string +} + +type Change struct { + Path string `json:"path"` + Before string `json:"before"` + After string `json:"after"` +} + +type Plan struct { + Schema string `json:"schema"` + Root string `json:"root"` + DryRun bool `json:"dry_run"` + Changes []Change `json:"changes"` + inputs map[string][]byte + overlay map[string][]byte +} + +// ReadInputs allows creation to preserve this planner's conflict checks when +// publishing mise files together with a future manifest and go.work. +func (p *Plan) ReadInputs() map[string][]byte { + out := map[string][]byte{} + for path, b := range p.inputs { + out[path] = bytes.Clone(b) + } + return out +} + +type config struct { + MinVersion string `toml:"min_version"` + MonorepoRoot bool `toml:"monorepo_root,omitempty"` + Monorepo *monorepo `toml:"monorepo,omitempty"` + Tools map[string]string `toml:"tools,omitempty"` + Tasks map[string]task `toml:"tasks,omitempty"` +} +type monorepo struct { + ConfigRoots []string `toml:"config_roots"` + Lockfile bool `toml:"lockfile"` +} +type task struct { + Description string `toml:"description"` + Run string `toml:"run"` + RunWindows string `toml:"run_windows"` +} + +func Enabled(root string) bool { + _, err := os.Lstat(filepath.Join(root, Filename)) + // Let Build report inaccessible or invalid configuration instead of + // silently skipping synchronization when it cannot be inspected. + return !os.IsNotExist(err) +} + +// Build is entirely static: no mise invocation, hooks, secrets, or network calls. +func Build(root string, opts Options) (*Plan, error) { + return BuildWithFiles(root, opts, nil) +} + +// BuildWithFiles validates the future workspace before creation publishes +// its manifest and language configuration. Overlay paths are root-relative. +func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan, error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + p := &Plan{Schema: "one-cli/mise-config/v1", Root: root, DryRun: true, Changes: []Change{}, inputs: map[string][]byte{}, overlay: files} + raw, err := p.read(workspace.ManifestFilename) + if err != nil { + return nil, err + } + var m workspace.Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + if _, err := workspace.ReadManifest(root); err != nil { + return nil, err + } + oldRoot, err := p.readOptional(Filename) + if err != nil { + return nil, err + } + var previous config + if oldRoot != nil { + if err := validateManaged(Filename, oldRoot); err != nil { + return nil, err + } + if err := toml.Unmarshal(oldRoot, &previous); err != nil { + return nil, err + } + } + if opts.GoVersion == "" { + opts.GoVersion = previous.Tools["go"] + } + if opts.NodeVersion == "" { + opts.NodeVersion = previous.Tools["node"] + } + if opts.NodeVersion == "" { + opts.NodeVersion = "24.15.0" + } + if !exactVersion.MatchString(opts.NodeVersion) { + return nil, fmt.Errorf("--node-version requires an exact version such as 24.15.0") + } + if opts.GoVersion != "" && !exactVersion.MatchString(opts.GoVersion) { + return nil, fmt.Errorf("--go-version requires an exact version such as 1.27.0") + } + rootConfig := config{MinVersion: runtimeport.MinimumMiseVersion, MonorepoRoot: true, Monorepo: &monorepo{ConfigRoots: []string{}}, Tools: map[string]string{}} + hooks, err := p.readOptional(workspace.HooksConfigFilename) + if err != nil { + return nil, err + } + if hooks != nil { + rootConfig.Tools[workspace.HKTool] = workspace.HKVersion + } + if opts.GoVersion != "" { + rootConfig.Tools["go"] = opts.GoVersion + } + rootPkg, err := p.readOptional("package.json") + if err != nil { + return nil, err + } + var pkg struct { + PackageManager string `json:"packageManager"` + } + if len(rootPkg) > 0 { + if err := json.Unmarshal(rootPkg, &pkg); err != nil { + return nil, err + } + } + if pkg.PackageManager != "" { + name, version, ok := strings.Cut(pkg.PackageManager, "@") + version, _, _ = strings.Cut(version, "+") // Corepack integrity suffix is not a mise version. + if !ok || !exactVersion.MatchString(version) { + return nil, fmt.Errorf("package.json packageManager must specify an exact version") + } + switch name { + case "pnpm", "npm", "yarn", "bun": + default: + return nil, fmt.Errorf("unsupported package manager %q", name) + } + rootConfig.Tools[name] = version + rootConfig.Tools["node"] = opts.NodeVersion + } + goWorkspace, err := gowork.Inspect(root, func(path string) ([]byte, error) { + rel, err := filepath.Rel(root, path) + if err != nil { + return nil, err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + // External user members are read-only inputs, never generated paths. + b, err := os.ReadFile(path) + if err == nil { + p.inputs[path] = b + } + return b, err + } + return p.readOptional(filepath.ToSlash(rel)) + }) + if err != nil { + return nil, err + } + if opts.GoVersion != "" && goWorkspace.Version != "" && compareVersions(opts.GoVersion, goWorkspace.Version) < 0 { + return nil, fmt.Errorf("Go %s is below the go.work requirement %s", opts.GoVersion, goWorkspace.Version) + } + for _, project := range m.Projects { + if !workspace.IsValidProjectName(project.Name) { + return nil, fmt.Errorf("invalid project name %q", project.Name) + } + rel := filepath.ToSlash(filepath.Clean(project.RelativeDir)) + if rel == "." || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, "../") { + return nil, conflict(rel, "project path must stay inside the workspace") + } + rootConfig.Monorepo.ConfigRoots = append(rootConfig.Monorepo.ConfigRoots, rel) + pc := config{MinVersion: runtimeport.MinimumMiseVersion, Tasks: map[string]task{}, Tools: map[string]string{}} + operations := []string{} + if workspace.ProjectDev(&m, project.Name) != "" { + operations = append(operations, "dev") + } + switch project.Toolchain { + case "node": + rootConfig.Tools["node"] = opts.NodeVersion + raw, err := p.read(rel + "/package.json") + if err != nil { + return nil, err + } + var scripts struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal(raw, &scripts); err != nil { + return nil, err + } + for _, op := range []string{"build", "test", "lint"} { + if scripts.Scripts[op] != "" { + operations = append(operations, op) + } + } + case "go": + raw, err := p.read(rel + "/go.mod") + if err != nil { + return nil, err + } + version, err := goVersion(raw, opts.GoVersion) + if err != nil { + return nil, fmt.Errorf("%s: %w", project.Name, err) + } + if !exactVersion.MatchString(version) { + return nil, fmt.Errorf("cannot determine Go version for %s; pass --go-version", project.Name) + } + if opts.GoVersion == "" { + if goWorkspace.Version != "" && compareVersions(version, goWorkspace.Version) < 0 { + version = goWorkspace.Version + } + pc.Tools["go"] = version + } + rawTask, err := p.readOptional(rel + "/Taskfile.yml") + if err != nil { + return nil, err + } + if rawTask == nil { + operations = append(operations, "build", "test", "lint") + } else { + pc.Tools["task"] = "3.51.1" + var tasks struct { + Tasks map[string]any `yaml:"tasks"` + } + if err := yaml.Unmarshal(rawTask, &tasks); err != nil { + return nil, err + } + for _, op := range []string{"build", "test", "lint"} { + if _, ok := tasks.Tasks[op]; ok { + operations = append(operations, op) + } + } + } + } + for _, op := range operations { + args := " __exec --protocol 1 --project " + project.Name + " --operation " + op + pc.Tasks["one:"+op] = task{ + Description: "One " + op + " for " + project.Name, + Run: "{{ env.ONE_BINARY_PATH | default(value='one') | quote }}" + args, + RunWindows: `if defined ONE_BINARY_PATH ("%ONE_BINARY_PATH%"` + args + `) else (one` + args + `)`, + } + } + if err := p.add(rel+"/"+Filename, pc); err != nil { + return nil, err + } + } + sort.Strings(rootConfig.Monorepo.ConfigRoots) + if err := p.add(Filename, rootConfig); err != nil { + return nil, err + } + sort.Slice(p.Changes, func(i, j int) bool { return p.Changes[i].Path < p.Changes[j].Path }) + return p, nil +} + +var exactVersion = regexp.MustCompile(`^\d+\.\d+\.\d+$`) +var goDirective = regexp.MustCompile(`(?m)^go\s+(\d+\.\d+(?:\.\d+)?)\s*$`) +var goToolchain = regexp.MustCompile(`(?m)^toolchain\s+go(\d+\.\d+\.\d+)\s*$`) + +func compareVersions(a, b string) int { + aa, bb := strings.Split(a, "."), strings.Split(b, ".") + for i := range 3 { + x, _ := strconv.Atoi(aa[i]) + y, _ := strconv.Atoi(bb[i]) + if x < y { + return -1 + } + if x > y { + return 1 + } + } + return 0 +} + +func goVersion(raw []byte, override string) (string, error) { + minimum := "" + if match := goDirective.FindSubmatch(raw); len(match) > 1 { + minimum = string(match[1]) + if strings.Count(minimum, ".") == 1 { + minimum += ".0" + } + } + if override != "" { + if minimum != "" && compareVersions(override, minimum) < 0 { + return "", fmt.Errorf("Go %s is below the go.mod requirement %s", override, minimum) + } + return override, nil + } + if match := goToolchain.FindSubmatch(raw); len(match) > 1 { + suggested := string(match[1]) + if minimum == "" || compareVersions(suggested, minimum) > 0 { + return suggested, nil + } + } + return minimum, nil +} + +func (p *Plan) add(rel string, value config) error { + before, err := p.readOptional(rel) + if err != nil { + return err + } + if before != nil { + if err := validateManaged(rel, before); err != nil { + return err + } + } + body, err := toml.Marshal(value) + if err != nil { + return err + } + after := []byte(fmt.Sprintf("%s%x\n# Edit user overrides in mise.toml; refresh with one configure mise.\n%s", header, sha256.Sum256(body), body)) + if !bytes.Equal(before, after) { + p.Changes = append(p.Changes, Change{Path: rel, Before: string(before), After: string(after)}) + } + return nil +} + +func validateManaged(path string, raw []byte) error { + lines := bytes.SplitN(raw, []byte("\n"), 3) + if len(lines) != 3 || string(lines[0]) != fmt.Sprintf("%s%x", header, sha256.Sum256(lines[2])) { + return conflict(path, "file is user-owned or has been modified; preserve it and resolve the conflict before regenerating") + } + return nil +} + +func (p *Plan) read(rel string) ([]byte, error) { + if err := p.safePath(rel); err != nil { + return nil, err + } + if raw, ok := p.overlay[rel]; ok { + return raw, nil + } + raw, err := os.ReadFile(filepath.Join(p.Root, filepath.FromSlash(rel))) + if err == nil { + if previous, exists := p.inputs[rel]; exists && (previous == nil || !bytes.Equal(previous, raw)) { + return nil, conflict(rel, "file changed while preparing configuration; retry") + } + p.inputs[rel] = raw + } + return raw, err +} +func (p *Plan) readOptional(rel string) ([]byte, error) { + raw, err := p.read(rel) + if os.IsNotExist(err) { + if previous, exists := p.inputs[rel]; exists && previous != nil { + return nil, conflict(rel, "file was removed while preparing configuration; retry") + } + p.inputs[rel] = nil + return nil, nil + } + return raw, err +} +func (p *Plan) safePath(rel string) error { + clean := filepath.Clean(filepath.FromSlash(rel)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return conflict(rel, "path escapes workspace") + } + current := p.Root + for _, part := range strings.Split(clean, string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return conflict(rel, "symbolic links are not supported in generated configuration paths") + } + } + return nil +} + +func (p *Plan) Apply(ctx context.Context) error { + if p.overlay != nil { + return fmt.Errorf("a projected mise plan must be applied by its workspace transaction") + } + unlock, err := fsutil.WorkspaceLock(ctx, p.Root, "mise") + if err != nil { + return err + } + defer unlock() + for rel, expected := range p.inputs { + path := rel + if !filepath.IsAbs(rel) { + if err := p.safePath(rel); err != nil { + return err + } + path = filepath.Join(p.Root, rel) + } + actual, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + if !bytes.Equal(actual, expected) || (expected == nil) != os.IsNotExist(err) { + return conflict(rel, "file changed after the plan was prepared; retry") + } + } + applied := []Change{} + for _, change := range p.Changes { + if err := writeAtomic(filepath.Join(p.Root, change.Path), []byte(change.After)); err != nil { + failures := []error{err} + for i := len(applied) - 1; i >= 0; i-- { + c := applied[i] + path := filepath.Join(p.Root, c.Path) + current, readErr := os.ReadFile(path) + if readErr != nil || string(current) != c.After { + failures = append(failures, conflict(c.Path, "could not restore configuration because the file changed during rollback")) + continue + } + var rollbackErr error + if c.Before == "" { + rollbackErr = os.Remove(path) + } else { + rollbackErr = writeAtomic(path, []byte(c.Before)) + } + if rollbackErr != nil { + failures = append(failures, fmt.Errorf("restore %s: %w", c.Path, rollbackErr)) + } + } + return errors.Join(failures...) + } + applied = append(applied, change) + } + p.DryRun = false + return nil +} + +func writeAtomic(path string, content []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".one-mise-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if err := f.Chmod(0o644); err != nil { + f.Close() + return err + } + _, err = f.Write(content) + if err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return fsutil.ReplaceFile(f.Name(), path) +} + +func conflict(path, reason string) error { + return cliErrors.New(cliErrors.MISE_CONFIG_CONFLICT, fmt.Sprintf("%s: %s", path, reason)).WithContext(map[string]any{"path": path}) +} diff --git a/packages/cli/internal/modules/miseconfig/config_test.go b/packages/cli/internal/modules/miseconfig/config_test.go new file mode 100644 index 0000000..51a24fa --- /dev/null +++ b/packages/cli/internal/modules/miseconfig/config_test.go @@ -0,0 +1,231 @@ +package miseconfig + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/pelletier/go-toml/v2" +) + +func fixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + files := map[string]string{ + "one.manifest.json": `{"version":1,"workspace":{"id":"mise-fixture","name":"fixture"},"projects":[{"name":"web","relativeDir":"apps/web","toolchain":"node","templateId":"react-spa","domains":{"dev":{"command":"pnpm dev"}}},{"name":"api","relativeDir":"services/api","toolchain":"go","templateId":"go-api","domains":{"dev":{"command":"go run ./cmd/server"}}}]}`, + "package.json": `{"packageManager":"pnpm@10.14.0"}`, + "apps/web/package.json": `{"scripts":{"dev":"vite","build":"vite build","test":"vitest run"}}`, + "services/api/go.mod": "module example.com/api\n\ngo 1.25.0\n", + "services/api/Taskfile.yml": "version: '3'\ntasks:\n build:\n cmds: ['go build ./...']\n test:\n cmds: ['go test ./...']\n", + "mise.toml": "# My existing configuration\n[env]\nKEEP = 'yes'\n", + } + for rel, raw := range files { + writeFixture(t, filepath.Join(root, rel), raw) + } + return root +} + +func writeFixture(t *testing.T, path, value string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(value), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestConfigurationGenerationIsAdditiveAndIdempotent(t *testing.T) { + root := fixture(t) + userBefore, _ := os.ReadFile(filepath.Join(root, "mise.toml")) + p, err := Build(root, Options{NodeVersion: "25.4.0"}) + if err != nil { + t.Fatal(err) + } + if len(p.Changes) != 3 || !p.DryRun { + t.Fatalf("unexpected plan: %+v", p) + } + if _, err := os.Stat(filepath.Join(root, Filename)); !os.IsNotExist(err) { + t.Fatal("planning wrote a file") + } + if err := p.Apply(context.Background()); err != nil { + t.Fatal(err) + } + rootRaw, _ := os.ReadFile(filepath.Join(root, Filename)) + var rootConfig config + if err := toml.Unmarshal(rootRaw, &rootConfig); err != nil { + t.Fatal(err) + } + if rootConfig.Tools["node"] != "25.4.0" || rootConfig.Tools["pnpm"] != "10.14.0" { + t.Fatalf("tools: %+v", rootConfig.Tools) + } + web, _ := os.ReadFile(filepath.Join(root, "apps/web", Filename)) + if !strings.Contains(string(web), "one:build") || strings.Contains(string(web), "one:lint") || strings.Contains(string(web), "vite") { + t.Fatalf("tasks do not reference the source commands: %s", web) + } + if !strings.Contains(string(web), "__exec --protocol 1 --project web --operation dev") { + t.Fatal("missing terminal execution leaf") + } + second, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if len(second.Changes) != 0 { + t.Fatalf("not idempotent: %+v", second.Changes) + } + userAfter, _ := os.ReadFile(filepath.Join(root, "mise.toml")) + if string(userBefore) != string(userAfter) { + t.Fatal("user TOML was modified") + } +} + +func TestGoVersionHonorsMinimumAndToolchain(t *testing.T) { + root := fixture(t) + writeFixture(t, filepath.Join(root, "services/api/go.mod"), "module example.com/api\n\ngo 1.25\ntoolchain go1.25.6\n") + p, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(root, "services/api", Filename)) + if err != nil || !strings.Contains(string(raw), "1.25.6") { + t.Fatalf("toolchain ignored: %v %s", err, raw) + } + if _, err := Build(root, Options{GoVersion: "1.24.0"}); err == nil { + t.Fatal("accepted Go below go.mod minimum") + } +} + +func TestGoWorkspaceVersionAppliesToMembersWithoutAddingGoToNode(t *testing.T) { + root := fixture(t) + writeFixture(t, filepath.Join(root, "go.work"), "go 1.27.0\nuse ./services/api\n") + p, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err != nil { + t.Fatal(err) + } + goConfig, _ := os.ReadFile(filepath.Join(root, "services/api", Filename)) + if !strings.Contains(string(goConfig), "go = '1.27.0'") { + t.Fatalf("workspace version ignored: %s", goConfig) + } + for _, rel := range []string{Filename, "apps/web/" + Filename} { + b, _ := os.ReadFile(filepath.Join(root, rel)) + if strings.Contains(string(b), "go =") { + t.Fatalf("Node inherited Go: %s", b) + } + } + if _, err := Build(root, Options{GoVersion: "1.26.0"}); err == nil { + t.Fatal("accepted override below workspace minimum") + } +} + +func TestManagedConfigurationConflictDoesNotOverwrite(t *testing.T) { + root := fixture(t) + p, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "apps/web", Filename) + raw, _ := os.ReadFile(path) + modified := string(raw) + "\n# My edit\n" + writeFixture(t, path, modified) + if _, err := Build(root, Options{}); err == nil { + t.Fatal("expected ownership conflict") + } + after, _ := os.ReadFile(path) + if string(after) != modified { + t.Fatal("conflict overwrote user's edit") + } +} + +func TestPlanRejectsChangedSourceBeforeWriting(t *testing.T) { + root := fixture(t) + p, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + writeFixture(t, filepath.Join(root, "apps/web/package.json"), `{"scripts":{"test":"changed"}}`) + if err := p.Apply(context.Background()); err == nil { + t.Fatal("expected stale-plan conflict") + } + if _, err := os.Stat(filepath.Join(root, Filename)); !os.IsNotExist(err) { + t.Fatal("stale plan wrote files") + } +} + +func TestPlanDistinguishesEmptySourceFromRemovedSource(t *testing.T) { + root := fixture(t) + path := filepath.Join(root, "services/api/Taskfile.yml") + writeFixture(t, path, "") + p, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err == nil { + t.Fatal("source deletion was ignored") + } + if _, err := os.Stat(filepath.Join(root, Filename)); !os.IsNotExist(err) { + t.Fatal("stale plan wrote files") + } +} + +func TestGeneratorRejectsSymlinkAndEmptyUserFile(t *testing.T) { + t.Run("empty user file", func(t *testing.T) { + root := fixture(t) + writeFixture(t, filepath.Join(root, Filename), "") + if _, err := Build(root, Options{}); err == nil { + t.Fatal("empty user file must not be claimed") + } + }) + t.Run("symlink", func(t *testing.T) { + root := fixture(t) + if err := os.Symlink(t.TempDir(), filepath.Join(root, ".mise")); err != nil { + t.Skip(err) + } + if _, err := Build(root, Options{}); err == nil { + t.Fatal("symlink target must not be written") + } + }) +} + +func TestFailedWriteRestoresAlreadyWrittenFiles(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission failure fixture is Unix-specific") + } + root := fixture(t) + p, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + blocked := filepath.Join(root, "apps/web/.mise/conf.d") + if err := os.MkdirAll(blocked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(blocked, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) }) + if f, err := os.Create(filepath.Join(blocked, "probe")); err == nil { + f.Close() + t.Skip("process bypasses Unix directory permissions") + } + if err := p.Apply(context.Background()); err == nil { + t.Fatal("expected write failure") + } + if _, err := os.Stat(filepath.Join(root, Filename)); !os.IsNotExist(err) { + t.Fatal("partial configuration was not rolled back") + } +} diff --git a/packages/cli/internal/platform/errors/codes.go b/packages/cli/internal/platform/errors/codes.go index 73111ed..6143634 100644 --- a/packages/cli/internal/platform/errors/codes.go +++ b/packages/cli/internal/platform/errors/codes.go @@ -21,6 +21,15 @@ const ( PROMPT_CANCELLED Code = "PROMPT_CANCELLED" OUTPUT_MARSHAL_FAILED Code = "OUTPUT_MARSHAL_FAILED" + // Optional tool runtime. + RUNTIME_INVALID Code = "RUNTIME_INVALID" + MISE_NOT_FOUND Code = "MISE_NOT_FOUND" + MISE_VERSION_UNSUPPORTED Code = "MISE_VERSION_UNSUPPORTED" + MISE_CONFIG_CONFLICT Code = "MISE_CONFIG_CONFLICT" + HOOKS_CONFIG_CONFLICT Code = "HOOKS_CONFIG_CONFLICT" + MISE_INSTALL_FAILED Code = "MISE_INSTALL_FAILED" + RUNTIME_TASK_NOT_FOUND Code = "RUNTIME_TASK_NOT_FOUND" + // Workspace / project. NOT_ONE_PROJECT Code = "NOT_ONE_PROJECT" NODE_VERSION_UNSUPPORTED Code = "NODE_VERSION_UNSUPPORTED" @@ -168,10 +177,17 @@ type Definition struct { // from here, so any published error-code reference can be made // authoritative by template-rendering this map. var Codes = map[Code]Definition{ - ONE_CLI_ERROR: {Summary: "Generic CLI failure with no specific code."}, - UNKNOWN_COMMAND: {Summary: "First positional argument did not match any known subcommand.", Remediation: []output.Remediation{{Action: "show-help", Hint: "查看可用命令", Command: "one --help"}}}, - PROMPT_CANCELLED: {Summary: "User cancelled an interactive prompt (Ctrl+C / ESC)."}, - OUTPUT_MARSHAL_FAILED: {Summary: "Internal: failed to marshal a result payload to JSON. Should never fire in practice."}, + RUNTIME_INVALID: {Summary: "The selected execution runtime is not builtin or mise."}, + MISE_NOT_FOUND: {Summary: "The explicitly selected mise executable is unavailable."}, + MISE_INSTALL_FAILED: {Summary: "One could not extract, verify, or prepare its bundled mise runtime."}, + MISE_VERSION_UNSUPPORTED: {Summary: "The installed mise version is unsupported or could not be read."}, + MISE_CONFIG_CONFLICT: {Summary: "A managed mise configuration was modified or changed during generation."}, + HOOKS_CONFIG_CONFLICT: {Summary: "Existing Git hooks or hk configuration conflict with One's generated setup."}, + RUNTIME_TASK_NOT_FOUND: {Summary: "The project does not provide the requested runtime task."}, + ONE_CLI_ERROR: {Summary: "Generic CLI failure with no specific code."}, + UNKNOWN_COMMAND: {Summary: "First positional argument did not match any known subcommand.", Remediation: []output.Remediation{{Action: "show-help", Hint: "查看可用命令", Command: "one --help"}}}, + PROMPT_CANCELLED: {Summary: "User cancelled an interactive prompt (Ctrl+C / ESC)."}, + OUTPUT_MARSHAL_FAILED: {Summary: "Internal: failed to marshal a result payload to JSON. Should never fire in practice."}, NOT_ONE_PROJECT: {Summary: "Current directory is not a One workspace (one.manifest.json is missing).", Remediation: []output.Remediation{{Action: "create-workspace", Hint: "当前目录缺少 one.manifest.json;请先创建工作区,或 cd 到已有工作区", Command: "one create "}}}, NODE_VERSION_UNSUPPORTED: {Summary: "Local Node version is below the supported minimum.", Remediation: []output.Remediation{{Action: "upgrade-node", Hint: "升级到 Node.js 18+"}}}, diff --git a/packages/cli/internal/platform/fsutil/plan.go b/packages/cli/internal/platform/fsutil/plan.go new file mode 100644 index 0000000..98089bd --- /dev/null +++ b/packages/cli/internal/platform/fsutil/plan.go @@ -0,0 +1,250 @@ +package fsutil + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gofrs/flock" +) + +// FilePlan stages related configuration writes and checks all read inputs +// before publishing. Callers hold WorkspaceLock across planning and applying. +type FilePlan struct { + Root string + inputs map[string][]byte + writes map[string][]byte + modes map[string]os.FileMode +} + +func NewFilePlan(root string) *FilePlan { + return &FilePlan{Root: root, inputs: map[string][]byte{}, writes: map[string][]byte{}, modes: map[string]os.FileMode{}} +} + +func (p *FilePlan) path(path string) string { + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + return filepath.Join(p.Root, filepath.FromSlash(path)) +} + +// Read also accepts absolute paths for read-only inputs such as external +// members of a user's go.work. Missing files return nil, nil. +func (p *FilePlan) Read(path string) ([]byte, error) { + path = p.path(path) + if b, ok := p.writes[path]; ok { + return b, nil + } + if b, ok := p.inputs[path]; ok { + return b, nil + } + b, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + p.inputs[path] = b + return b, nil +} + +// Expect imports a read snapshot from a cooperating planner. This prevents +// a file changed between planning and Set from being mistaken for user input +// that was already validated by that planner. +func (p *FilePlan) Expect(path string, expected []byte) error { + path = p.path(path) + if previous, ok := p.inputs[path]; ok && (!bytes.Equal(previous, expected) || (previous == nil) != (expected == nil)) { + return fmt.Errorf("%s changed between workspace plans; retry", path) + } + p.inputs[path] = bytes.Clone(expected) + return nil +} + +func (p *FilePlan) Set(path string, b []byte, mode os.FileMode) error { + path = p.path(path) + if err := SafeWritePath(p.Root, path); err != nil { + return err + } + if _, err := p.Read(path); err != nil { + return err + } + if info, err := os.Stat(path); err == nil { + mode = info.Mode().Perm() + } + p.writes[path], p.modes[path] = b, mode + return nil +} + +// Remove stages deletion with the same snapshot and rollback guarantees as Set. +func (p *FilePlan) Remove(path string) error { + return p.Set(path, nil, 0o644) +} + +type FileChange struct { + Path string `json:"path"` + Before string `json:"before"` + After string `json:"after"` + Remove bool `json:"remove,omitempty"` +} + +func (p *FilePlan) Changes() []FileChange { + changes := []FileChange{} + for path, after := range p.writes { + before := p.inputs[path] + if bytes.Equal(before, after) && (before == nil) == (after == nil) { + continue + } + rel, _ := filepath.Rel(p.Root, path) + changes = append(changes, FileChange{Path: filepath.ToSlash(rel), Before: string(before), After: string(after), Remove: after == nil}) + } + sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path }) + return changes +} + +// Overlay is suitable for another static planner reading the future files. +func (p *FilePlan) Overlay() map[string][]byte { + out := map[string][]byte{} + for path, b := range p.writes { + rel, _ := filepath.Rel(p.Root, path) + out[filepath.ToSlash(rel)] = b + } + return out +} + +func (p *FilePlan) Apply(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + for path, expected := range p.inputs { + actual, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + if !bytes.Equal(actual, expected) || (expected == nil) != os.IsNotExist(err) { + return fmt.Errorf("%s changed while preparing the workspace; retry", path) + } + } + paths := make([]string, 0, len(p.writes)) + for path, b := range p.writes { + if err := SafeWritePath(p.Root, path); err != nil { + return err + } + if !bytes.Equal(b, p.inputs[path]) || (b == nil) != (p.inputs[path] == nil) { + paths = append(paths, path) + } + } + sort.Strings(paths) + applied := []string{} + for _, path := range paths { + err := ctx.Err() + if err == nil { + if p.writes[path] == nil { + err = os.Remove(path) + } else { + err = WriteAtomic(path, p.writes[path], p.modes[path]) + } + } + if err != nil { + failures := []error{err} + for i := len(applied) - 1; i >= 0; i-- { + path := applied[i] + current, readErr := os.ReadFile(path) + if (readErr != nil && !os.IsNotExist(readErr)) || !bytes.Equal(current, p.writes[path]) || (p.writes[path] == nil) != os.IsNotExist(readErr) { + failures = append(failures, fmt.Errorf("cannot restore concurrently modified %s", path)) + continue + } + var restoreErr error + if p.inputs[path] == nil { + restoreErr = os.Remove(path) + } else { + restoreErr = WriteAtomic(path, p.inputs[path], p.modes[path]) + } + if restoreErr != nil { + failures = append(failures, restoreErr) + } + } + return errors.Join(failures...) + } + applied = append(applied, path) + } + return nil +} + +// SafeWritePath rejects escaping paths and symlink traversal before mutation. +func SafeWritePath(root, path string) error { + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("configuration path escapes workspace: %s", path) + } + current := root + for _, part := range strings.Split(rel, string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("configuration path is a symbolic link: %s", current) + } + } + return nil +} + +func WriteAtomic(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".one-config-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if err := f.Chmod(mode); err != nil { + f.Close() + return err + } + _, err = f.Write(data) + if err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return ReplaceFile(f.Name(), path) +} + +// WorkspaceLock leaves no metadata in the user's repository. Different +// purposes can serialize independent resources (creation, node, go). +func WorkspaceLock(ctx context.Context, root, purpose string) (func(), error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, err + } + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved + } + lock := flock.New(filepath.Join(os.TempDir(), fmt.Sprintf("one-%s-%x.lock", purpose, sha256.Sum256([]byte(root))))) + locked, err := lock.TryLockContext(ctx, 50*time.Millisecond) + if err != nil { + return nil, err + } + if !locked { + return nil, fmt.Errorf("workspace %s is locked", purpose) + } + return func() { _ = lock.Unlock() }, nil +} diff --git a/packages/cli/internal/platform/fsutil/plan_test.go b/packages/cli/internal/platform/fsutil/plan_test.go new file mode 100644 index 0000000..af01efc --- /dev/null +++ b/packages/cli/internal/platform/fsutil/plan_test.go @@ -0,0 +1,104 @@ +package fsutil + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestFilePlanChecksEveryInputBeforeWriting(t *testing.T) { + root := t.TempDir() + input := filepath.Join(root, "source") + if err := os.WriteFile(input, []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + p := NewFilePlan(root) + if _, err := p.Read("source"); err != nil { + t.Fatal(err) + } + if err := p.Set("new", []byte("new content"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(input, []byte("user update"), 0o644); err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err == nil { + t.Fatal("stale plan applied") + } + if _, err := os.Stat(filepath.Join(root, "new")); !os.IsNotExist(err) { + t.Fatal("stale plan wrote files") + } +} + +func TestFilePlanRefusesSymlinkWrites(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink privileges vary") + } + root, external := t.TempDir(), t.TempDir() + if err := os.Symlink(external, filepath.Join(root, "linked")); err != nil { + t.Fatal(err) + } + if err := NewFilePlan(root).Set("linked/file", []byte("should not write"), 0o644); err == nil { + t.Fatal("accepted symlink traversal") + } +} + +func TestWorkspaceLockCancelsWaitingCallerAndCanBeReacquired(t *testing.T) { + root := t.TempDir() + unlock, err := WorkspaceLock(context.Background(), root, "plan-test") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + if release, err := WorkspaceLock(ctx, root, "plan-test"); err == nil { + release() + t.Fatal("acquired held lock") + } + unlock() + release, err := WorkspaceLock(context.Background(), root, "plan-test") + if err != nil { + t.Fatal(err) + } + release() +} + +func TestFilePlanDeletionChecksInputsAndDistinguishesEmptyFiles(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "legacy") + if err := os.WriteFile(path, []byte("original"), 0o755); err != nil { + t.Fatal(err) + } + p := NewFilePlan(root) + if err := p.Remove("legacy"); err != nil { + t.Fatal(err) + } + if len(p.Changes()) != 1 || !p.Changes()[0].Remove { + t.Fatal("missing deletion preview") + } + if err := os.WriteFile(path, []byte("edited"), 0o755); err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err == nil { + t.Fatal("deleted concurrent edit") + } + p = NewFilePlan(root) + if err := p.Remove("legacy"); err != nil { + t.Fatal(err) + } + if err := p.Set("empty", []byte{}, 0o644); err != nil { + t.Fatal(err) + } + if err := p.Apply(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("legacy file remains") + } + if b, err := os.ReadFile(filepath.Join(root, "empty")); err != nil || len(b) != 0 { + t.Fatal("empty file wasn't created") + } +} diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 48b3f3c..39b29bb 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -134,7 +134,7 @@ "ci.error.confirmation_required": "Disabling continuous integration non-interactively requires --yes.", "ci.error.confirmation_hint": "Review the selected projects, then confirm removal explicitly", "configure.short": "Manage local connections and preferences", - "configure.tip": "Manages service connections and preferences stored only on this machine. Secrets are never written to the workspace or Git; first use starts the connection wizard.", + "configure.tip": "Manages service connections and preferences stored only on this machine. Secrets are never written to the workspace or Git; first use starts the connection wizard. Use configure mise to generate workspace tool configuration.", "configure.summary_title": "Local connections:", "configure.no_connections": " No local connections have been created.", "configure.default_marker": " [current]", @@ -235,6 +235,7 @@ "workspace.dev_ready": "ready to develop", "workspace.dev_unavailable": "no local development command", "workspace.dependencies_missing": "dependencies need installation", + "workspace.dependencies_unverified": "dependencies checked at startup", "workspace.deploy_missing": "deployment target not configured", "workspace.deploy_ready": "deployment configured", "workspace.environment": "Environment variables: %s, default %s", @@ -289,6 +290,13 @@ "dev.tip": "Starts every developable project when no project is given, or only the named project. If Node dependencies are missing, you will be asked before they are installed.", "dev.dependencies_missing": "Node dependencies required for local development are not installed.", "dev.install_hint": "Install workspace dependencies, then rerun the development command", + "dev.install_mise_hint": "Run this installation command from the workspace root, then retry one dev. One prepares the mise runtime automatically.", + "mise.short": "Run mise commands using the runtime managed by One", + "hooks.short": "Run workspace checks and fixes with hk through One", + "hooks.tip": "Use one hk check for changed files, one hk check --all for CI, and one hk fix for explicit fixes. Git hook installation and migration are managed by one configure hooks. hk and its tool environment run through One's bundled mise; application dependencies remain project-specific.", + "hooks.configure_short": "Generate hk checks, migrate default Husky hooks, and install local Git launchers", + "create.hooks_warning": "Workspace created, but Git hooks need setup (%v); run one configure hooks after resolving it", + "mise.tip": "Optional access to mise for configuration trust, diagnostics, and tool installation. One includes a pinned mise runtime and extracts it locally on first use without downloading mise. Arguments and output are passed through; project environment secrets are not loaded by One here. Normal development continues to use one dev and one run.", "dev.install_confirm": "Dependencies are not installed. Run `%s` now and continue?", "dev.package_manager_missing": "Package manager %s was not found.", "dev.install_failed": "Running %s failed: %v", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 111b681..6e51dde 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -134,7 +134,7 @@ "ci.error.confirmation_required": "非交互停用持续集成时必须传入 --yes。", "ci.error.confirmation_hint": "检查所选项目后,再显式确认删除", "configure.short": "管理本机连接和偏好设置", - "configure.tip": "管理只保存在本机的服务连接与偏好设置。密钥不会写入工作区或 Git;首次使用会进入建立连接向导。", + "configure.tip": "管理只保存在本机的服务连接与偏好设置。密钥不会写入工作区或 Git;首次使用会进入建立连接向导。configure mise 用于生成工作区工具配置。", "configure.summary_title": "本机连接:", "configure.no_connections": " 尚未建立任何本机连接。", "configure.default_marker": " [当前使用]", @@ -235,6 +235,7 @@ "workspace.dev_ready": "可开始开发", "workspace.dev_unavailable": "没有本地开发命令", "workspace.dependencies_missing": "需要安装依赖", + "workspace.dependencies_unverified": "启动时检查依赖", "workspace.deploy_missing": "尚未设置部署目标", "workspace.deploy_ready": "部署已配置", "workspace.environment": "环境变量:%s,默认 %s", @@ -289,6 +290,13 @@ "dev.tip": "不指定项目时启动全部可开发项目;传入项目名时只启动该项目。检测到 Node 依赖缺失时会先询问是否安装。", "dev.dependencies_missing": "本地开发所需的 Node 依赖尚未安装。", "dev.install_hint": "先安装工作区依赖,再重新运行开发命令", + "dev.install_mise_hint": "在工作区根目录运行此安装命令,再重试 one dev。One 会自动准备 mise runtime。", + "mise.short": "通过 One 管理的 runtime 运行 mise 命令", + "hooks.short": "通过 One 运行 hk 工作区检查与修复", + "hooks.tip": "one hk check 检查变更文件,one hk check --all 用于 CI,one hk fix 显式修复。通过 one configure hooks 安装或迁移 Git hooks。hk 及工具环境由 One 内置的 mise 提供;项目依赖由各项目管理。", + "hooks.configure_short": "生成 hk 检查、迁移默认 Husky hooks 并安装本地 Git 启动器", + "create.hooks_warning": "工作区已创建,但 Git hooks 尚未安装(%v);解决后运行 one configure hooks", + "mise.tip": "按需访问 mise 的配置信任、诊断和工具安装功能。One 已内置固定版本的 mise,首次使用从自身解压,无需下载 mise。参数和输出直接透传,此入口不加载 One 的项目密钥。日常开发继续使用 one dev 和 one run。", "dev.install_confirm": "尚未安装依赖。现在运行 `%s` 并继续吗?", "dev.package_manager_missing": "找不到包管理器 %s。", "dev.install_failed": "运行 %s 失败:%v", diff --git a/packages/cli/internal/platform/process/cancel_unix.go b/packages/cli/internal/platform/process/cancel_unix.go new file mode 100644 index 0000000..989c524 --- /dev/null +++ b/packages/cli/internal/platform/process/cancel_unix.go @@ -0,0 +1,27 @@ +//go:build !windows + +package process + +import ( + "os" + "os/exec" + "syscall" + "time" +) + +// CancelProcessTree prevents cancelled preparation from leaving Go compilers, +// package managers or lifecycle scripts running after the supervisor exits. +func CancelProcessTree(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return os.ErrProcessDone + } + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if err == syscall.ESRCH { + return os.ErrProcessDone + } + return err + } + cmd.WaitDelay = 5 * time.Second +} diff --git a/packages/cli/internal/platform/process/cancel_windows.go b/packages/cli/internal/platform/process/cancel_windows.go new file mode 100644 index 0000000..e4dbff4 --- /dev/null +++ b/packages/cli/internal/platform/process/cancel_windows.go @@ -0,0 +1,19 @@ +package process + +import ( + "os" + "os/exec" + "strconv" + "time" +) + +func CancelProcessTree(cmd *exec.Cmd) { + cmd.Cancel = func() error { + if cmd.Process == nil { + return os.ErrProcessDone + } + _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run() + return cmd.Process.Kill() + } + cmd.WaitDelay = 5 * time.Second +} diff --git a/packages/cli/internal/platform/process/exit_unix.go b/packages/cli/internal/platform/process/exit_unix.go new file mode 100644 index 0000000..351c3fb --- /dev/null +++ b/packages/cli/internal/platform/process/exit_unix.go @@ -0,0 +1,15 @@ +//go:build !windows + +package process + +import ( + "os/exec" + "syscall" +) + +func signalExitCode(err *exec.ExitError) int { + if status, ok := err.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()) + } + return 1 +} diff --git a/packages/cli/internal/platform/process/exit_windows.go b/packages/cli/internal/platform/process/exit_windows.go new file mode 100644 index 0000000..ece513a --- /dev/null +++ b/packages/cli/internal/platform/process/exit_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package process + +import "os/exec" + +func signalExitCode(_ *exec.ExitError) int { return 1 } diff --git a/packages/cli/internal/platform/process/forward.go b/packages/cli/internal/platform/process/forward.go new file mode 100644 index 0000000..c59d61f --- /dev/null +++ b/packages/cli/internal/platform/process/forward.go @@ -0,0 +1,49 @@ +package process + +import ( + "context" + "errors" + "os" + "os/exec" + "os/signal" + "syscall" +) + +// ExitStatus preserves application exit codes without terminating the CLI before cleanup. +type ExitStatus struct{ Code int } + +func (e *ExitStatus) Error() string { return "child process exited unsuccessfully" } + +func RunForwarded(ctx context.Context, child *exec.Cmd) error { + if err := child.Start(); err != nil { + return err + } + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + done := make(chan struct{}) + defer close(done) + go func() { + for { + select { + case sig := <-signals: + _ = child.Process.Signal(sig) + case <-ctx.Done(): + _ = child.Process.Kill() + return + case <-done: + return + } + } + }() + err := child.Wait() + var exit *exec.ExitError + if errors.As(err, &exit) { + code := exit.ExitCode() + if code < 0 { + code = signalExitCode(exit) + } + return &ExitStatus{Code: code} + } + return err +} diff --git a/packages/cli/internal/ports/runtime/runtime.go b/packages/cli/internal/ports/runtime/runtime.go new file mode 100644 index 0000000..5e84c74 --- /dev/null +++ b/packages/cli/internal/ports/runtime/runtime.go @@ -0,0 +1,36 @@ +// Package runtime defines the boundary for preparing a command's tool environment. +package runtime + +import ( + "context" + "fmt" + + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" +) + +const ( + Builtin = "builtin" + Mise = "mise" + MinimumMiseVersion = "2026.9.7" +) + +type Command struct { + Directory string + Argv []string + Env []string +} + +// Provider wraps a command without executing that command or loading One secrets. +type Provider interface { + Prepare(context.Context, Command) (Command, error) + // PrepareCLI addresses the runtime's own commands (for example trust or + // doctor) without loading the project's runtime environment first. + PrepareCLI(context.Context, Command) (Command, error) +} + +func Validate(kind string) error { + if kind == "" || kind == Builtin || kind == Mise { + return nil + } + return cliErrors.New(cliErrors.RUNTIME_INVALID, fmt.Sprintf("Unknown runtime %q; use builtin or mise.", kind)) +} diff --git a/packages/cli/internal/transport/cobra/configure/cmd.go b/packages/cli/internal/transport/cobra/configure/cmd.go index f110789..1b4129d 100644 --- a/packages/cli/internal/transport/cobra/configure/cmd.go +++ b/packages/cli/internal/transport/cobra/configure/cmd.go @@ -48,6 +48,8 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + hookscmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/hooks" + misecmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/mise" servecmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/serve" ) @@ -108,6 +110,8 @@ func buildContributions( }), buildLocaleCmd(), } parent.AddCommand(children...) + parent.AddCommand(misecmd.Commands()...) + parent.AddCommand(hookscmd.ConfigureCommand()) i18n.MarkShort(parent, "configure.short") i18n.MarkLong(parent, "configure.tip") return []*cobra.Command{parent} diff --git a/packages/cli/internal/transport/cobra/configure/cmd_test.go b/packages/cli/internal/transport/cobra/configure/cmd_test.go index 1379c1f..c0d7e2d 100644 --- a/packages/cli/internal/transport/cobra/configure/cmd_test.go +++ b/packages/cli/internal/transport/cobra/configure/cmd_test.go @@ -53,6 +53,8 @@ func TestBuildContributionsTreeShape(t *testing.T) { "use": true, "remove": true, "open": true, + "mise": true, // Workspace tool configuration; existing profile verbs stay intact. + "hooks": true, // `locale` is the first (and so far only) user-global // preference under `configure`. Unlike the verbs above it // doesn't take a (domain, backend) pair — it just reads / diff --git a/packages/cli/internal/transport/cobra/create/create.go b/packages/cli/internal/transport/cobra/create/create.go index a6e920b..404c3cd 100644 --- a/packages/cli/internal/transport/cobra/create/create.go +++ b/packages/cli/internal/transport/cobra/create/create.go @@ -177,12 +177,16 @@ func runCreate(deps Dependencies, cmd *cobra.Command, rawDir string, flags *crea Schema: "one-cli/create/v2", ProjectName: projectName, CreatedPath: result.TargetDir, + displayPath: displayPath, CreatedInPlace: result.CreatedInPlace, PackageManager: result.PackageManager, SecretsBackend: secretsBackend, CIEnabled: ciEnabled, DevEnabled: devEnabled, } + if result.HooksWarn != nil { + payload.Warnings = []string{i18n.Tf("create.hooks_warning", result.HooksWarn)} + } output.Emit(&payload) return nil diff --git a/packages/cli/internal/transport/cobra/create/preset.go b/packages/cli/internal/transport/cobra/create/preset.go index e531446..367c428 100644 --- a/packages/cli/internal/transport/cobra/create/preset.go +++ b/packages/cli/internal/transport/cobra/create/preset.go @@ -191,6 +191,9 @@ func runCreateWithPreset(deps Dependencies, cmd *cobra.Command, cwd, rawDir stri }, PartialState: creationResult.PartialState, } + if creationResult.HooksWarn != nil { + payload.Warnings = []string{i18n.Tf("create.hooks_warning", creationResult.HooksWarn)} + } if len(creationResult.Preset.UnknownSegments) > 0 { payload.UnknownSegments = creationResult.Preset.UnknownSegments } @@ -228,6 +231,7 @@ func presetProjectsPayload(projects []creationmodule.ProjectResult) []presetProj // non-preset path continues to emit createResult (v2) so its snapshot // fixture stays stable. type createPresetResult struct { + Warnings []string `json:"warnings,omitempty"` Schema string `json:"schema"` Preset presetEnvelope `json:"preset"` ProjectName string `json:"project_name"` @@ -268,7 +272,7 @@ func (r *createPresetResult) RenderTTY(w io.Writer) { return } fmt.Fprintf(w, i18n.T("create.preset_success")+"\n", r.Preset.ID) - fmt.Fprintf(w, i18n.T("create.location")+"\n", r.CreatedPath) + fmt.Fprintf(w, i18n.T("create.location")+"\n", compactHomePath(r.CreatedPath)) for _, p := range r.Projects { if p.DeployBackend != "" { fmt.Fprintf(w, i18n.T("create.preset_project_deployed")+"\n", p.Name, p.TemplateID, p.DeployBackend) @@ -277,4 +281,7 @@ func (r *createPresetResult) RenderTTY(w io.Writer) { } } fmt.Fprintf(w, i18n.T("create.env_source")+"\n", r.EnvSummary.Backend) + for _, warning := range r.Warnings { + fmt.Fprintln(w, warning) + } } diff --git a/packages/cli/internal/transport/cobra/create/result.go b/packages/cli/internal/transport/cobra/create/result.go index d0ee9e7..caee511 100644 --- a/packages/cli/internal/transport/cobra/create/result.go +++ b/packages/cli/internal/transport/cobra/create/result.go @@ -10,17 +10,20 @@ import ( cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/userdirs" ) type createResult struct { - Schema string `json:"schema"` - ProjectName string `json:"project_name"` - CreatedPath string `json:"created_path"` - CreatedInPlace bool `json:"created_in_place"` - PackageManager string `json:"package_manager"` - SecretsBackend string `json:"secrets_backend,omitempty"` - CIEnabled bool `json:"ci_enabled"` - DevEnabled bool `json:"dev_enabled"` + Schema string `json:"schema"` + ProjectName string `json:"project_name"` + CreatedPath string `json:"created_path"` + CreatedInPlace bool `json:"created_in_place"` + PackageManager string `json:"package_manager"` + SecretsBackend string `json:"secrets_backend,omitempty"` + CIEnabled bool `json:"ci_enabled"` + DevEnabled bool `json:"dev_enabled"` + Warnings []string `json:"warnings,omitempty"` + displayPath string } // RenderTTY prints a friendly create-success summary. @@ -29,8 +32,10 @@ func (r *createResult) RenderTTY(w io.Writer) { return } fmt.Fprintf(w, i18n.T("create.success")+"\n", r.ProjectName) - fmt.Fprintf(w, i18n.T("create.location")+"\n", r.CreatedPath) - fmt.Fprintf(w, i18n.T("create.package_manager")+"\n", r.PackageManager) + fmt.Fprintf(w, i18n.T("create.location")+"\n", compactHomePath(r.CreatedPath)) + if r.PackageManager != "" { + fmt.Fprintf(w, i18n.T("create.package_manager")+"\n", r.PackageManager) + } if r.SecretsBackend == "" || r.SecretsBackend == workspace.EnvBackendDotenv { fmt.Fprintln(w, i18n.T("create.env_local")) } else { @@ -42,12 +47,34 @@ func (r *createResult) RenderTTY(w io.Writer) { if r.DevEnabled { fmt.Fprintln(w, i18n.T("create.dev_enabled")) } + for _, warning := range r.Warnings { + fmt.Fprintln(w, warning) + } fmt.Fprintln(w) fmt.Fprintln(w, i18n.T("common.next_steps")) - fmt.Fprintf(w, " cd %s\n", r.CreatedPath) + cdPath := r.displayPath + if cdPath == "" { + cdPath = r.CreatedPath + } + fmt.Fprintf(w, " cd %s\n", cdPath) fmt.Fprintln(w, " one add") } +func compactHomePath(path string) string { + home, err := userdirs.Home() + if err != nil { + return path + } + rel, err := filepath.Rel(home, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return path + } + if rel == "." { + return "~" + } + return "~/" + filepath.ToSlash(rel) +} + func relativeOrAbs(cwd, targetDir string, useCurrentDir bool) string { rel, err := filepath.Rel(cwd, targetDir) if err == nil && rel != "" { diff --git a/packages/cli/internal/transport/cobra/dev/cmd.go b/packages/cli/internal/transport/cobra/dev/cmd.go index 1b97e86..6bccf44 100644 --- a/packages/cli/internal/transport/cobra/dev/cmd.go +++ b/packages/cli/internal/transport/cobra/dev/cmd.go @@ -5,31 +5,27 @@ package devcmd import ( "fmt" - "os" - "os/exec" - "path/filepath" "strings" "github.com/spf13/cobra" "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/dependencies" processorch "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/development/process" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/helpui" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" - platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/prompt" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" ) -func Commands() []*cobra.Command { return buildContributions() } +func Commands(provider runtimeport.Provider) []*cobra.Command { return buildContributions(provider) } -func buildContributions() []*cobra.Command { - return []*cobra.Command{newDevCmd()} +func buildContributions(provider runtimeport.Provider) []*cobra.Command { + return []*cobra.Command{newDevCmd(provider)} } -func newDevCmd() *cobra.Command { +func newDevCmd(provider runtimeport.Provider) *cobra.Command { var ( project string dryRun bool @@ -56,16 +52,21 @@ func newDevCmd() *cobra.Command { return err } root := activeWorkspace.Root() + runtimeKind, err := execution.RuntimeKind(root) + if err != nil { + return err + } processName, err := resolveProcessSelector(activeWorkspace, project) if err != nil { return err } if !dryRun { - if err := ensureDependencies(cmd, root, activeWorkspace.Manifest(), processName); err != nil { + if err := (dependencies.Service{Provider: provider}).Prepare(cmd.Context(), dependencies.Input{Root: root, Manifest: activeWorkspace.Manifest(), Project: processName, Runtime: runtimeKind, Log: cmd.ErrOrStderr()}); err != nil { return err } } res, err := processorch.Start(cmd.Context(), processorch.StartInput{ + Runtime: runtimeKind, ProjectRoot: root, DryRun: dryRun, Process: processName, @@ -93,91 +94,6 @@ func newDevCmd() *cobra.Command { return cmd } -func ensureDependencies(cmd *cobra.Command, root string, manifest *workspace.Manifest, projectName string) error { - needsInstall := false - packageManager := "" - for i := range manifest.Projects { - p := &manifest.Projects[i] - if projectName != "" && p.Name != projectName { - continue - } - if p.Toolchain != "node" || strings.TrimSpace(workspace.ProjectDev(manifest, p.Name)) == "" { - continue - } - projectDir := filepath.Join(root, filepath.FromSlash(p.RelativeDir)) - if !workspace.ProjectDependenciesInstalled(root, projectDir, p.Toolchain) { - needsInstall = true - } - if packageManager == "" { - packageManager = strings.TrimSpace(p.PackageManager) - } - } - if !needsInstall { - return nil - } - install := dependencyInstallCommand(root, packageManager) - installLine := strings.Join(install, " ") - if !output.CanPrompt() { - return cliErrors.New(cliErrors.DEPENDENCIES_NOT_INSTALLED, - i18n.T("dev.dependencies_missing")). - WithRemediation(output.Remediation{ - Action: "install-dependencies", - Hint: i18n.T("dev.install_hint"), - Command: installLine, - }) - } - ok, err := prompt.Confirm(i18n.Tf("dev.install_confirm", installLine), true, - i18n.T("common.install_continue"), i18n.T("common.cancel")) - if err != nil { - return err - } - if !ok { - return cliErrors.New(cliErrors.PROMPT_CANCELLED, i18n.T("common.cancelled")).WithExit0() - } - if _, err := exec.LookPath(install[0]); err != nil { - return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, - i18n.Tf("dev.package_manager_missing", install[0])) - } - child := platformprocess.CommandContext(cmd.Context(), install[0], install[1:]...) - child.Dir = root - child.Stdin = os.Stdin - child.Stdout = os.Stdout - child.Stderr = os.Stderr - if err := child.Run(); err != nil { - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - i18n.Tf("dev.install_failed", installLine, err)) - } - return nil -} - -func dependencyInstallCommand(root, projectPackageManager string) []string { - manager := strings.TrimSpace(projectPackageManager) - if pkg, err := workspace.ReadPackageJSON(root); err == nil && pkg != nil && pkg.PackageManager != "" { - manager = pkg.PackageManager - } - if i := strings.Index(manager, "@"); i > 0 { - manager = manager[:i] - } - if manager == "" { - switch { - case fileExists(filepath.Join(root, "bun.lock")), fileExists(filepath.Join(root, "bun.lockb")): - manager = "bun" - case fileExists(filepath.Join(root, "yarn.lock")): - manager = "yarn" - case fileExists(filepath.Join(root, "package-lock.json")): - manager = "npm" - default: - manager = "pnpm" - } - } - return []string{manager, "install"} -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - // resolveProcessSelector turns the user-facing -p value into a manifest // project name (which equals the Procfile.dev entry name). Empty input // yields empty output, meaning "all processes". diff --git a/packages/cli/internal/transport/cobra/hooks/cmd.go b/packages/cli/internal/transport/cobra/hooks/cmd.go new file mode 100644 index 0000000..b3e3b9e --- /dev/null +++ b/packages/cli/internal/transport/cobra/hooks/cmd.go @@ -0,0 +1,111 @@ +package hookscmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/hooks" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" +) + +func ConfigureCommand() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "hooks", Args: cobra.NoArgs, + Example: " one configure hooks --dry-run -o json\n one configure hooks\n one hk check --all", + RunE: func(cmd *cobra.Command, _ []string) error { + w, err := execution.ResolveWorkspace(cmd.Context()) + if err != nil { + return err + } + result, err := hooks.Configure(cmd.Context(), w.Root(), "", dryRun) + if err != nil { + return err + } + output.Emit(result) + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview configuration, migration, and local hook files without changing anything") + i18n.MarkShort(cmd, "hooks.configure_short") + return cmd +} + +func Commands(provider runtimeport.Provider) []*cobra.Command { + cmd := &cobra.Command{ + Use: "hk [args...]", DisableFlagParsing: true, + Example: " one hk check\n one hk check --all\n one hk fix\n one hk validate", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { + return cmd.Help() + } + if args[0] == "install" || args[0] == "init" || args[0] == "uninstall" { + return fmt.Errorf("One manages the Git launchers; use one configure hooks to generate or reinstall them, and edit hk.pkl to customize checks") + } + dir, err := workspace.ResolveProjectRoot("") + if err != nil { + return err + } + binary, err := os.Executable() + if err != nil { + return err + } + env := commandEnv(os.Environ(), binary) + argv := append([]string{"exec", workspace.HKTool + "@" + workspace.HKVersion, "--", "hk"}, args...) + prepared, err := provider.PrepareCLI(cmd.Context(), runtimeport.Command{Directory: dir, Argv: argv, Env: env}) + if err != nil { + return err + } + child := platformprocess.Command(prepared.Argv[0], prepared.Argv[1:]...) + child.Dir, child.Env = prepared.Directory, prepared.Env + child.Stdin, child.Stdout, child.Stderr = cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr() + return platformprocess.RunForwarded(cmd.Context(), child) + }, + } + i18n.MarkShort(cmd, "hooks.short") + i18n.MarkLong(cmd, "hooks.tip") + return []*cobra.Command{cmd, gofmtCommand()} +} + +func commandEnv(env []string, binary string) []string { + result := make([]string, 0, len(env)+2) + for _, item := range env { + key, _, _ := strings.Cut(item, "=") + if strings.EqualFold(key, "ONE_BINARY_PATH") { + continue + } + result = append(result, item) + } + return append(result, "ONE_BINARY_PATH="+filepath.Clean(binary)) +} + +// gofmt returns success for unformatted input, so hk needs a small portable +// read-only adapter. The enclosing mise step already supplies the project Go. +func gofmtCommand() *cobra.Command { + return &cobra.Command{ + Use: "__hook-gofmt", Hidden: true, Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, files []string) error { + args := append([]string{"-l", "--"}, files...) + child := exec.CommandContext(cmd.Context(), "gofmt", args...) + child.Stderr = cmd.ErrOrStderr() + out, err := child.Output() + if err != nil { + return fmt.Errorf("gofmt failed: %w", err) + } + if len(out) != 0 { + fmt.Fprint(cmd.OutOrStdout(), string(out)) + return fmt.Errorf("Go files need formatting; run one hk fix") + } + return nil + }, + } +} diff --git a/packages/cli/internal/transport/cobra/hooks/cmd_test.go b/packages/cli/internal/transport/cobra/hooks/cmd_test.go new file mode 100644 index 0000000..3b894e1 --- /dev/null +++ b/packages/cli/internal/transport/cobra/hooks/cmd_test.go @@ -0,0 +1,88 @@ +package hookscmd + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" +) + +type recordingProvider struct{ command runtimeport.Command } + +func (p *recordingProvider) Prepare(context.Context, runtimeport.Command) (runtimeport.Command, error) { + panic("hk should use the runtime CLI entry") +} +func (p *recordingProvider) PrepareCLI(_ context.Context, c runtimeport.Command) (runtimeport.Command, error) { + p.command = c + c.Argv = []string{"sh", "-c", "printf hk-output; exit 37"} + return c, nil +} + +func TestHKForwardsArgumentsAndExitStatus(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX fake process") + } + root := t.TempDir() + t.Chdir(root) + if err := os.WriteFile(filepath.Join(root, ".env"), []byte("ONE_HOOK_SECRET=must-not-load\n"), 0o644); err != nil { + t.Fatal(err) + } + p := &recordingProvider{} + cmd := Commands(p)[0] + cmd.SilenceUsage, cmd.SilenceErrors = true, true + var out bytes.Buffer + cmd.SetOut(&out) + args := []string{"run", "commit-msg", "a b'$(literal).txt"} + cmd.SetArgs(args) + err := cmd.Execute() + status, ok := err.(*platformprocess.ExitStatus) + if !ok || status.Code != 37 { + t.Fatalf("exit: %#v", err) + } + if out.String() != "hk-output" { + t.Fatalf("stdout: %s", out.String()) + } + want := []string{"exec", workspace.HKTool + "@" + workspace.HKVersion, "--", "hk"} + want = append(want, args...) + if strings.Join(p.command.Argv, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("argv: %#v", p.command.Argv) + } + for _, item := range p.command.Env { + if strings.HasPrefix(item, "ONE_HOOK_SECRET=") { + t.Fatal("loaded app secrets") + } + } +} + +func TestGofmtAdapterRejectsUnformattedAndInvalidInput(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "sample.go") + for _, tc := range []struct { + code string + valid bool + }{{"package sample\n", true}, {"package sample;func f(){ }\n", false}, {"not Go syntax\n", false}} { + if err := os.WriteFile(path, []byte(tc.code), 0o644); err != nil { + t.Fatal(err) + } + cmd := gofmtCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--", path}) + err := cmd.Execute() + if (err == nil) != tc.valid { + t.Fatalf("input %q: %v %s", tc.code, err, out.String()) + } + after, _ := os.ReadFile(path) + if string(after) != tc.code { + t.Fatal("check modified source") + } + } +} diff --git a/packages/cli/internal/transport/cobra/mise/cmd.go b/packages/cli/internal/transport/cobra/mise/cmd.go new file mode 100644 index 0000000..0f01d9a --- /dev/null +++ b/packages/cli/internal/transport/cobra/mise/cmd.go @@ -0,0 +1,40 @@ +// Package misecmd exposes explicit, reviewable mise configuration generation. +package misecmd + +import ( + "github.com/spf13/cobra" + "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/miseconfig" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" +) + +func Commands() []*cobra.Command { + var opts miseconfig.Options + var dryRun bool + cmd := &cobra.Command{ + Use: "mise", Short: "Generate or refresh optional mise tool and task configuration", + Example: " one configure mise --dry-run -o json\n one configure mise\n one dev web", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + w, err := execution.ResolveWorkspace(cmd.Context()) + if err != nil { + return err + } + plan, err := miseconfig.Build(w.Root(), opts) + if err != nil { + return err + } + if !dryRun { + if err := plan.Apply(cmd.Context()); err != nil { + return err + } + } + output.Emit(plan) + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print proposed file contents without writing files or executing mise") + cmd.Flags().StringVar(&opts.NodeVersion, "node-version", "", "Exact Node version (default: previous generated version or 24.15.0)") + cmd.Flags().StringVar(&opts.GoVersion, "go-version", "", "Exact Go version override (default: each project's go.mod)") + return []*cobra.Command{cmd} +} diff --git a/packages/cli/internal/transport/cobra/mise/passthrough.go b/packages/cli/internal/transport/cobra/mise/passthrough.go new file mode 100644 index 0000000..d8cce5c --- /dev/null +++ b/packages/cli/internal/transport/cobra/mise/passthrough.go @@ -0,0 +1,39 @@ +package misecmd + +import ( + "os" + + "github.com/spf13/cobra" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" +) + +// RuntimeCommands exposes maintenance of the same mise executable used by +// one run/dev. In particular, trust must work before mise exec can load config. +func RuntimeCommands(provider runtimeport.Provider) []*cobra.Command { + cmd := &cobra.Command{ + Use: "mise [args...]", DisableFlagParsing: true, + Example: " one mise trust\n one mise doctor\n one mise exec -- pnpm install", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { + return cmd.Help() + } + dir, err := os.Getwd() + if err != nil { + return err + } + prepared, err := provider.PrepareCLI(cmd.Context(), runtimeport.Command{Directory: dir, Argv: args, Env: os.Environ()}) + if err != nil { + return err + } + child := platformprocess.Command(prepared.Argv[0], prepared.Argv[1:]...) + child.Dir, child.Env = prepared.Directory, prepared.Env + child.Stdin, child.Stdout, child.Stderr = cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr() + return platformprocess.RunForwarded(cmd.Context(), child) + }, + } + i18n.MarkShort(cmd, "mise.short") + i18n.MarkLong(cmd, "mise.tip") + return []*cobra.Command{cmd} +} diff --git a/packages/cli/internal/transport/cobra/run/cmd.go b/packages/cli/internal/transport/cobra/run/cmd.go index e95ac59..a294a9f 100644 --- a/packages/cli/internal/transport/cobra/run/cmd.go +++ b/packages/cli/internal/transport/cobra/run/cmd.go @@ -10,11 +10,9 @@ import ( "fmt" "os" "os/exec" - "os/signal" "path/filepath" "runtime" "strings" - "syscall" "github.com/spf13/cobra" @@ -24,11 +22,12 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" ) -func Commands(loaders *secrets.Registry) []*cobra.Command { - return []*cobra.Command{newRunCmd(loaders)} +func Commands(loaders *secrets.Registry, provider runtimeport.Provider) []*cobra.Command { + return []*cobra.Command{newRunCmd(loaders, provider), newExecCmd(loaders)} } // newRunCmd wires `one run` — exec a passthrough command with the resolved @@ -48,13 +47,18 @@ func Commands(loaders *secrets.Registry) []*cobra.Command { // SIGINT/SIGTERM are forwarded so Ctrl-C kills the child first; we exit with // the child's exit code so scripts and CI can branch normally. type runFlags struct { - project string - envName string - envProvider string + project string + envName string + envProvider string + runtime string + prepared bool + provider runtimeport.Provider + dryRun bool + outputFormat string } -func newRunCmd(loaders *secrets.Registry) *cobra.Command { - flags := &runFlags{} +func newRunCmd(loaders *secrets.Registry, provider runtimeport.Provider) *cobra.Command { + flags := &runFlags{provider: provider} cmd := &cobra.Command{ Use: "run [project] [-p ] [--env-provider dotenv|infisical] [--env ] -- [args...]", DisableFlagsInUseLine: true, @@ -92,6 +96,7 @@ func newRunCmd(loaders *secrets.Registry) *cobra.Command { Args: cobra.ArbitraryArgs, DisableFlagParsing: false, RunE: func(cmd *cobra.Command, args []string) error { + flags.outputFormat, _ = cmd.Flags().GetString("output") commandArgs, err := parseRunArgs(cmd, flags, args) if err != nil { return err @@ -108,6 +113,7 @@ func newRunCmd(loaders *secrets.Registry) *cobra.Command { cmd.Flags().StringVarP(&flags.project, "project", "p", "", "项目名(manifest.projects[].name)或相对路径;默认从 cwd 推导") cmd.Flags().StringVar(&flags.envName, "env", "", "环境名(默认取 manifest.environments.default)") cmd.Flags().StringVar(&flags.envProvider, "env-provider", "", "env provider: dotenv | infisical(默认取 workspace manifest 中已选的值)") + cmd.Flags().BoolVar(&flags.dryRun, "dry-run", false, "Print the execution plan without loading environment values or starting a command") i18n.MarkShort(cmd, "run.short") return cmd } @@ -173,6 +179,48 @@ func runRun(ctx context.Context, loaders *secrets.Registry, flags *runFlags, arg return err } + if !flags.prepared { + flags.runtime, err = execution.RuntimeKind(projectRoot) + if err != nil { + return err + } + } + if err := runtimeport.Validate(flags.runtime); err != nil { + return err + } + if flags.dryRun { + output.Emit(map[string]any{"schema": "one-cli/run-plan/v1", "runtime": flags.runtime, "directory": targetDir, "argv": args, "environment": flags.envName, "dry_run": true}) + return nil + } + if flags.runtime == runtimeport.Mise { + binary, err := os.Executable() + if err != nil { + return err + } + childArgs := []string{binary, "__exec", "--protocol", "1", "--project", relativeDir} + if flags.outputFormat != "" { + childArgs = append(childArgs, "--output", flags.outputFormat) + } + if flags.envName != "" { + childArgs = append(childArgs, "--env", flags.envName) + } + if flags.envProvider != "" { + childArgs = append(childArgs, "--env-provider", flags.envProvider) + } + childArgs = append(append(childArgs, "--"), args...) + if flags.provider == nil { + return cliErrors.New(cliErrors.ONE_CLI_ERROR, "mise runtime is not configured") + } + prepared, err := flags.provider.Prepare(ctx, runtimeport.Command{Directory: targetDir, Argv: childArgs, Env: os.Environ()}) + if err != nil { + return err + } + child := platformprocess.Command(prepared.Argv[0], prepared.Argv[1:]...) + child.Dir, child.Env = prepared.Directory, prepared.Env + child.Stdin, child.Stdout, child.Stderr = os.Stdin, os.Stdout, os.Stderr + return platformprocess.RunForwarded(ctx, child) + } + vars, source, err := loadRunSecrets( ctx, loaders, flags, projectRoot, activeWorkspace.Manifest(), relativeDir, ) @@ -194,7 +242,11 @@ func runRun(ctx context.Context, loaders *secrets.Registry, flags *runFlags, arg // inherited PATH last — this is the same precedence npm/pnpm use. childEnv = augmentPathForRun(childEnv, projectRoot, targetDir) - binary, err := lookPathFor(args[0], childEnv) + commandName := args[0] + if !filepath.IsAbs(commandName) && strings.ContainsAny(commandName, "/\\") { + commandName = filepath.Join(targetDir, commandName) + } + binary, err := lookPathFor(commandName, childEnv) if err != nil { return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, fmt.Sprintf("命令未找到:%s(已在 PATH 中查过:含 %s/node_modules/.bin 与 %s/node_modules/.bin)", @@ -211,41 +263,12 @@ func runRun(ctx context.Context, loaders *secrets.Registry, flags *runFlags, arg child.Env = childEnv child.Dir = targetDir - if err := child.Start(); err != nil { - return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, - fmt.Sprintf("启动 %s 失败:%v", args[0], err)). - WithContext(map[string]any{"command": args[0]}) - } - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - done := make(chan struct{}) - go func() { - for { - select { - case sig := <-sigCh: - if child.Process != nil { - _ = child.Process.Signal(sig) - } - case <-done: - return - } - } - }() - - waitErr := child.Wait() - close(done) - signal.Stop(sigCh) - - if waitErr != nil { - var exitErr *exec.ExitError - if errors.As(waitErr, &exitErr) { - os.Exit(exitErr.ExitCode()) - } - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("等待子进程失败:%v", waitErr)) + err = platformprocess.RunForwarded(ctx, child) + var exit *platformprocess.ExitStatus + if err != nil && !errors.As(err, &exit) { + return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, fmt.Sprintf("启动 %s 失败:%v", args[0], err)) } - return nil + return err } // loadRunSecrets resolves the secret source per --env-provider and returns diff --git a/packages/cli/internal/transport/cobra/run/exec.go b/packages/cli/internal/transport/cobra/run/exec.go new file mode 100644 index 0000000..40cb4d7 --- /dev/null +++ b/packages/cli/internal/transport/cobra/run/exec.go @@ -0,0 +1,49 @@ +package runcmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" + "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" +) + +// __exec is the terminal leaf of mise execution. It must never enter mise again. +func newExecCmd(loaders *secrets.Registry) *cobra.Command { + flags := &runFlags{prepared: true} + var protocol int + var operation string + cmd := &cobra.Command{ + Use: "__exec", Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + if protocol != 1 { + return fmt.Errorf("unsupported One execution protocol %d; regenerate the mise configuration", protocol) + } + if flags.project == "" { + return fmt.Errorf("internal execution requires --project") + } + if operation != "" { + if len(args) > 0 { + return fmt.Errorf("generated operation tasks do not accept extra arguments; use one run for custom commands") + } + w, err := execution.ResolveWorkspace(cmd.Context()) + if err != nil { + return err + } + args, err = execution.OperationArgs(w, flags.project, operation) + if err != nil { + return err + } + } else if cmd.ArgsLenAtDash() != 0 || len(args) == 0 { + return fmt.Errorf("internal execution requires -- followed by a command") + } + return runRun(cmd.Context(), loaders, flags, args) + }, + } + cmd.Flags().IntVar(&protocol, "protocol", 0, "Execution protocol version") + cmd.Flags().StringVar(&flags.project, "project", "", "Project selector") + cmd.Flags().StringVar(&flags.envName, "env", "", "Environment") + cmd.Flags().StringVar(&flags.envProvider, "env-provider", "", "Environment provider") + cmd.Flags().StringVar(&operation, "operation", "", "Project operation") + return cmd +} diff --git a/packages/cli/testdata/reference/create-default.json b/packages/cli/testdata/reference/create-default.json index 033f534..90dc8c0 100644 --- a/packages/cli/testdata/reference/create-default.json +++ b/packages/cli/testdata/reference/create-default.json @@ -3,7 +3,7 @@ "created_in_place": false, "created_path": "\u003cvolatile\u003e", "dev_enabled": true, - "package_manager": "pnpm", + "package_manager": "", "project_name": "my-app", "schema": "one-cli/create/v2", "secrets_backend": "dotenv" diff --git a/packages/cli/testdata/reference/help/configure.txt b/packages/cli/testdata/reference/help/configure.txt index b96f9b4..1b065bf 100644 --- a/packages/cli/testdata/reference/help/configure.txt +++ b/packages/cli/testdata/reference/help/configure.txt @@ -8,8 +8,10 @@ USAGE SUBCOMMANDS add Create or update a local connection current Show the current local connection + hooks Generate hk checks, migrate default Husky hooks, and install local Git launchers list List local connections locale Show or set the display language (auto / zh-CN / en-US) + mise Generate or refresh optional mise tool and task configuration open Open the local settings page remove Remove a local connection show Show a local connection (secrets masked by default) @@ -21,7 +23,7 @@ EXAMPLES one configure list TIPS -Manages service connections and preferences stored only on this machine. Secrets are never written to the workspace or Git; first use starts the connection wizard. +Manages service connections and preferences stored only on this machine. Secrets are never written to the workspace or Git; first use starts the connection wizard. Use configure mise to generate workspace tool configuration. COMMON OPTIONS -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/configure_hooks.txt b/packages/cli/testdata/reference/help/configure_hooks.txt new file mode 100644 index 0000000..b4970b1 --- /dev/null +++ b/packages/cli/testdata/reference/help/configure_hooks.txt @@ -0,0 +1,15 @@ + +DESCRIPTION +Generate hk checks, migrate default Husky hooks, and install local Git launchers + +USAGE + one configure hooks [flags] + +EXAMPLES + one configure hooks --dry-run -o json + one configure hooks + one hk check --all + +COMMON OPTIONS + --dry-run Preview configuration, migration, and local hook files without changing anything + -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/configure_mise.txt b/packages/cli/testdata/reference/help/configure_mise.txt new file mode 100644 index 0000000..f77b71e --- /dev/null +++ b/packages/cli/testdata/reference/help/configure_mise.txt @@ -0,0 +1,17 @@ + +DESCRIPTION +Generate or refresh optional mise tool and task configuration + +USAGE + one configure mise [flags] + +EXAMPLES + one configure mise --dry-run -o json + one configure mise + one dev web + +COMMON OPTIONS + --dry-run Print proposed file contents without writing files or executing mise + --go-version Exact Go version override (default: each project's go.mod) + --node-version Exact Node version (default: previous generated version or 24.15.0) + -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/hk.txt b/packages/cli/testdata/reference/help/hk.txt new file mode 100644 index 0000000..4fc1601 --- /dev/null +++ b/packages/cli/testdata/reference/help/hk.txt @@ -0,0 +1,18 @@ + +DESCRIPTION +Run workspace checks and fixes with hk through One + +USAGE + one hk [args...] [flags] + +EXAMPLES + one hk check + one hk check --all + one hk fix + one hk validate + +TIPS +Use one hk check for changed files, one hk check --all for CI, and one hk fix for explicit fixes. Git hook installation and migration are managed by one configure hooks. hk and its tool environment run through One's bundled mise; application dependencies remain project-specific. + +COMMON OPTIONS + -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/mise.txt b/packages/cli/testdata/reference/help/mise.txt new file mode 100644 index 0000000..2fa6bfb --- /dev/null +++ b/packages/cli/testdata/reference/help/mise.txt @@ -0,0 +1,17 @@ + +DESCRIPTION +Run mise commands using the runtime managed by One + +USAGE + one mise [args...] [flags] + +EXAMPLES + one mise trust + one mise doctor + one mise exec -- pnpm install + +TIPS +Optional access to mise for configuration trust, diagnostics, and tool installation. One includes a pinned mise runtime and extracts it locally on first use without downloading mise. Arguments and output are passed through; project environment secrets are not loaded by One here. Normal development continues to use one dev and one run. + +COMMON OPTIONS + -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/run.txt b/packages/cli/testdata/reference/help/run.txt index c5a4e8e..d04b170 100644 --- a/packages/cli/testdata/reference/help/run.txt +++ b/packages/cli/testdata/reference/help/run.txt @@ -39,6 +39,7 @@ TIPS one run --env staging -- npm run e2e # 用 staging 环境的密钥 COMMON OPTIONS + --dry-run Print the execution plan without loading environment values or starting a command --env 环境名(默认取 manifest.environments.default) --env-provider env provider: dotenv | infisical(默认取 workspace manifest 中已选的值) -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/tests/e2e/e2e_helpers_test.go b/packages/cli/tests/e2e/e2e_helpers_test.go index 8a9f44c..e349a02 100644 --- a/packages/cli/tests/e2e/e2e_helpers_test.go +++ b/packages/cli/tests/e2e/e2e_helpers_test.go @@ -280,6 +280,11 @@ func assertSnapshot(t *testing.T, name string, got map[string]any) { // per-test HOME. func isolateHome(t *testing.T, dir string) { t.Helper() + // The existing suite pins the native execution contract without installing + // project toolchains. Runtime integration tests explicitly clear this override. + t.Setenv("ONE_RUNTIME", "builtin") + t.Setenv("ONE_MISE_BINARY", "") + t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, ".cache")) t.Setenv("HOME", dir) t.Setenv("XDG_CONFIG_HOME", "") } diff --git a/packages/cli/tests/e2e/mise_runtime_test.go b/packages/cli/tests/e2e/mise_runtime_test.go new file mode 100644 index 0000000..bcca5a0 --- /dev/null +++ b/packages/cli/tests/e2e/mise_runtime_test.go @@ -0,0 +1,346 @@ +package cli_test + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func runtimeFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + isolateHome(t, root) + t.Setenv("ONE_RUNTIME", "") + files := map[string]string{ + "one.manifest.json": `{"version":1,"workspace":{"id":"runtime-test","name":"runtime-test"},"projects":[{"name":"web","relativeDir":"apps/web","toolchain":"node","templateId":"react-spa","domains":{"dev":{"command":"node dev.cjs"}}},{"name":"api","relativeDir":"services/api","toolchain":"go","templateId":"go-api","domains":{"dev":{"command":"node dev.cjs"}}}]}`, + ".mise/conf.d/one.toml": "[env]\nONE_MISE_TEST_VALUE = 'root'\nONE_MISE_PARENT = 'root-only'\n", + "apps/web/.mise/conf.d/one.toml": "[env]\nONE_MISE_TEST_VALUE = 'project'\nONE_MISE_ONLY = 'from-mise'\n", + "apps/web/.env": "ONE_MISE_TEST_VALUE=web-secret\nWEB_ONLY=web-only\n", + "services/api/.env": "ONE_MISE_TEST_VALUE=api-secret\nAPI_ONLY=api-only\n", + "apps/web/package.json": `{"scripts":{"dev":"node dev.cjs"}}`, + } + for rel, content := range files { + path := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func installFakeMise(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("POSIX fake mise; real integration can run on Windows") + } + dir := t.TempDir() + script := "#!/bin/sh\nif [ \"$1\" = --version ]; then echo '2026.9.7 linux-x64'; exit 0; fi\n[ \"$1\" = exec ] && [ \"$2\" = -- ] || exit 91\nshift 2\nexport ONE_MISE_TEST_VALUE=from-mise\nexport ONE_MISE_ONLY=from-mise\nexec \"$@\"\n" + if err := os.WriteFile(filepath.Join(dir, "mise"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("ONE_MISE_BINARY", filepath.Join(dir, "mise")) +} + +func TestE2E_MiseRuntimeOriginalRunSyntaxAndExitCode(t *testing.T) { + root := runtimeFixture(t) + installFakeMise(t) + args := append([]string{"run", "web", "--"}, environmentEchoCommand("ONE_MISE_TEST_VALUE")...) + out, errOut, code := runBinaryIn(t, root, args...) + if code != 0 || strings.TrimSpace(out) != "web-secret" { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } + args = append([]string{"run", "web", "--"}, exitWithCodeCommand(42)...) + _, _, code = runBinaryIn(t, root, args...) + if code != 42 { + t.Fatalf("exit code = %d, want 42", code) + } + // Literal argv must survive mise and the terminal One execution leaf. + args = []string{"run", "web", "--", "sh", "-c", `printf '<%s>\n' "$@"`, "probe", "a b", "", `$(not-a-command)`, "中文", `a'b"c`} + out, errOut, code = runBinaryIn(t, root, args...) + if code != 0 || out != "\n<>\n<$(not-a-command)>\n<中文>\n\n" { + t.Fatalf("argv: code=%d stdout=%q stderr=%q", code, out, errOut) + } +} + +func TestE2E_MisePassthroughKeepsArgumentsExitAndDoesNotLoadOneSecrets(t *testing.T) { + root := runtimeFixture(t) + installFakeMise(t) + out, stderr, code := runBinaryIn(t, root, "mise", "--version") + if code != 0 || !strings.HasPrefix(out, "2026.9.7") { + t.Fatalf("version: %d %q %s", code, out, stderr) + } + out, stderr, code = runBinaryIn(t, filepath.Join(root, "apps/web"), "mise", "exec", "--", "sh", "-c", `printf '<%s>\n' "${WEB_ONLY:-not-loaded}" "$@"`, "probe", "a b", "", `$(literal)`) + if code != 0 || out != "\n\n<>\n<$(literal)>\n" { + t.Fatalf("passthrough: %d %q %s", code, out, stderr) + } + _, _, code = runBinaryIn(t, root, "mise", "exec", "--", "sh", "-c", "exit 37") + if code != 37 { + t.Fatalf("passthrough exit = %d", code) + } +} + +func TestE2E_MiseTrustThroughOneBeforeRunningProject(t *testing.T) { + mise := os.Getenv("ONE_TEST_MISE_BINARY") + if mise == "" { + t.Skip("set ONE_TEST_MISE_BINARY for real trust integration") + } + root := runtimeFixture(t) + useRealMise(t, root, mise) + t.Setenv("MISE_TRUSTED_CONFIG_PATHS", "") + t.Setenv("MISE_PARANOID", "1") + // Static env values need no trust; command templates deliberately do. + if err := os.WriteFile(filepath.Join(root, "apps/web/.mise/conf.d/one.toml"), []byte(`[env] +ONE_MISE_TEST_VALUE = '{{ exec(command="echo from-mise") }}' +`), 0o644); err != nil { + t.Fatal(err) + } + args := append([]string{"run", "web", "--"}, environmentEchoCommand("ONE_MISE_TEST_VALUE")...) + if _, _, code := runBinaryIn(t, root, args...); code == 0 { + t.Fatal("untrusted configuration ran") + } + for _, rel := range []string{".mise/conf.d/one.toml", "apps/web/.mise/conf.d/one.toml"} { + out, stderr, code := runBinaryIn(t, root, "mise", "trust", filepath.Join(root, rel)) + if code != 0 { + t.Fatalf("trust: %d %q %s", code, out, stderr) + } + } + out, stderr, code := runBinaryIn(t, root, args...) + if code != 0 || strings.TrimSpace(out) != "web-secret" { + t.Fatalf("trusted run: %d %q %s", code, out, stderr) + } +} + +func TestE2E_MiseMissingReportsErrorAndLegacyStillRuns(t *testing.T) { + root := runtimeFixture(t) + t.Setenv("PATH", t.TempDir()) + t.Setenv("ONE_MISE_BINARY", filepath.Join(t.TempDir(), "missing-mise")) + out, stderr, code := runBinaryIn(t, root, "run", "web", "--", "unavailable-command") + if code == 0 || !strings.Contains(out+stderr, "MISE_NOT_FOUND") { + t.Fatalf("missing mise: %d %s %s", code, out, stderr) + } + // Removing only the One-generated root configuration models an old workspace. + if err := os.Remove(filepath.Join(root, ".mise/conf.d/one.toml")); err != nil { + t.Fatal(err) + } + out, stderr, code = runBinaryIn(t, root, "run", "web", "--dry-run", "--", "unavailable-command") + if code != 0 || !strings.Contains(out, `"runtime": "builtin"`) { + t.Fatalf("legacy runtime: %d %s %s", code, out, stderr) + } +} + +func TestE2E_MiseDryRunDoesNotRunHooksOrNeedMise(t *testing.T) { + root := runtimeFixture(t) + probe := filepath.Join(t.TempDir(), "mise") + if runtime.GOOS != "windows" { + if err := os.WriteFile(probe, []byte("#!/bin/sh\ntouch \""+filepath.Join(root, "mise-was-called")+"\"\nexit 99\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", filepath.Dir(probe)+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("ONE_MISE_BINARY", probe) + } + out, errOut, code := runBinaryIn(t, root, "run", "web", "--dry-run", "--", "does-not-exist") + if code != 0 { + t.Fatalf("dry run: %d %s", code, errOut) + } + var plan struct { + Runtime string `json:"runtime"` + DryRun bool `json:"dry_run"` + } + if err := json.Unmarshal([]byte(out), &plan); err != nil { + t.Fatal(err) + } + if plan.Runtime != "mise" || !plan.DryRun || strings.Contains(out, "web-secret") { + t.Fatalf("bad plan: %s", out) + } + if _, err := os.Stat(filepath.Join(root, ".cache/one/runtimes/mise")); !os.IsNotExist(err) { + t.Fatal("dry-run prepared the bundled runtime") + } + if _, err := os.Stat(filepath.Join(root, "mise-was-called")); !os.IsNotExist(err) { + t.Fatal("preview invoked mise") + } +} + +func TestE2E_MiseRealConfigurationLayering(t *testing.T) { + mise := os.Getenv("ONE_TEST_MISE_BINARY") + if mise == "" { + t.Skip("set ONE_TEST_MISE_BINARY to run the real mise integration") + } + root := runtimeFixture(t) + useRealMise(t, root, mise) + for _, pair := range []struct{ project, key, want string }{ + {"web", "ONE_MISE_TEST_VALUE", "web-secret"}, {"web", "ONE_MISE_ONLY", "from-mise"}, + {"web", "ONE_MISE_PARENT", "root-only"}, {"api", "ONE_MISE_TEST_VALUE", "api-secret"}, + } { + args := append([]string{"run", pair.project, "--"}, environmentEchoCommand(pair.key)...) + out, stderr, code := runBinaryIn(t, root, args...) + if code != 0 || strings.TrimSpace(out) != pair.want { + t.Fatalf("%+v: code=%d stdout=%q stderr=%q", pair, code, out, stderr) + } + } + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node required for environment isolation assertion") + } + out, stderr, code := runBinaryIn(t, root, "run", "api", "--", "node", "-p", "process.env.WEB_ONLY || 'isolated'") + if code != 0 || strings.TrimSpace(out) != "isolated" { + t.Fatalf("project leak: %d %q %s", code, out, stderr) + } +} + +func useRealMise(t *testing.T, root, mise string) { + t.Helper() + t.Setenv("PATH", filepath.Dir(mise)+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("ONE_MISE_BINARY", mise) + isolateMiseState(t, root) +} + +func isolateMiseState(t *testing.T, root string) { + t.Helper() + for _, key := range []string{"MISE_DATA_DIR", "MISE_STATE_DIR", "MISE_CACHE_DIR", "MISE_CONFIG_DIR"} { + t.Setenv(key, filepath.Join(root, key)) + } + t.Setenv("MISE_TRUSTED_CONFIG_PATHS", root) + // Never fetch tools during this environment/dispatch contract test. + t.Setenv("MISE_AUTO_INSTALL", "false") +} + +// No runtime network or preinstalled mise is needed, including on first use. +func TestE2E_MiseBundledFirstRunWithoutMiseOnPath(t *testing.T) { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Skip("Linux amd64 offline fixture") + } + root := runtimeFixture(t) + isolateMiseState(t, root) + t.Setenv("PATH", "/usr/bin:/bin") + if _, err := exec.LookPath("mise"); err == nil { + t.Skip("fixture requires mise absent from system directories") + } + for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"} { + t.Setenv(key, "http://127.0.0.1:1") + } + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + t.Setenv("MISE_AUTO_UPDATE", "true") + t.Setenv("MISE_DISABLE_UPDATE_WARNING", "false") + out, stderr, code := runBinaryIn(t, root, "run", "web", "--", "/bin/sh", "-c", `printf '%s' "$ONE_MISE_TEST_VALUE"`) + if code != 0 || out != "web-secret" || !strings.Contains(stderr, "Preparing bundled mise") { + t.Fatalf("first run: %d %q %s", code, out, stderr) + } + // The user's auto-update preference must not mutate One's pinned runtime. + out, stderr, code = runBinaryIn(t, root, "run", "web", "--", "/bin/sh", "-c", `printf '%s %s' "$MISE_AUTO_UPDATE" "$MISE_DISABLE_UPDATE_WARNING"`) + if code != 0 || out != "false true" { + t.Fatalf("bundled update settings: %d %q %s", code, out, stderr) + } + path := filepath.Join(root, ".cache/one/runtimes/mise/2026.9.7/linux-x64-musl/mise") + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } + out, stderr, code = runBinaryIn(t, root, "run", "web", "--", "mise", "--version") + if code != 0 || !strings.HasPrefix(out, "2026.9.7") || strings.Contains(stderr, "Preparing bundled mise") { + t.Fatalf("cached run: %d %q %s", code, out, stderr) + } + out, stderr, code = runBinaryIn(t, root, "mise", "--version") + if code != 0 || !strings.HasPrefix(out, "2026.9.7") { + t.Fatalf("offline mise command: %d %q %s", code, out, stderr) + } + if _, err := os.Stat(filepath.Join(root, "MISE_CACHE_DIR", "latest-version")); !os.IsNotExist(err) { + t.Fatal("bundled runtime performed a version update check") + } + +} + +func TestE2E_MiseCreateAddAndRefreshWithoutNewFlags(t *testing.T) { + tmp := t.TempDir() + isolateHome(t, tmp) + t.Setenv("ONE_RUNTIME", "") + root := bootstrapWorkspace(t, tmp, "original-commands") + for _, p := range []struct{ template, name string }{{"react-spa", "web"}, {"go-api", "api"}} { + out, stderr, code := runBinaryIn(t, root, "add", p.template, "--name", p.name, "-y", "-o", "json") + if code != 0 { + t.Fatalf("add %s: %d %s %s", p.name, code, out, stderr) + } + } + for _, rel := range []string{".mise/conf.d/one.toml", "apps/web/.mise/conf.d/one.toml", "services/api/.mise/conf.d/one.toml"} { + raw, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil || !strings.Contains(string(raw), "Managed by One CLI") { + t.Fatalf("configuration %s: %v %s", rel, err, raw) + } + } + out, stderr, code := runBinaryIn(t, root, "configure", "mise", "--dry-run", "-o", "json") + if code != 0 || !strings.Contains(out, `"changes": []`) { + t.Fatalf("refresh: %d %s %s", code, out, stderr) + } + out, stderr, code = runBinaryIn(t, root, "run", "web", "--dry-run", "--", "node", "--version") + if code != 0 || !strings.Contains(out, `"runtime": "mise"`) { + t.Fatalf("automatic runtime: %d %s %s", code, out, stderr) + } + // A modified generated fragment must fail before rendering a new project. + path := filepath.Join(root, ".mise/conf.d/one.toml") + raw, _ := os.ReadFile(path) + if err := os.WriteFile(path, append(raw, []byte("\n# edited\n")...), 0o644); err != nil { + t.Fatal(err) + } + manifest, _ := os.ReadFile(filepath.Join(root, "one.manifest.json")) + _, _, code = runBinaryIn(t, root, "add", "react-spa", "--name", "another", "-y") + if code == 0 { + t.Fatal("add succeeded with conflicting configuration") + } + after, _ := os.ReadFile(filepath.Join(root, "one.manifest.json")) + if string(after) != string(manifest) { + t.Fatal("conflict changed manifest") + } + if _, err := os.Stat(filepath.Join(root, "apps/another")); !os.IsNotExist(err) { + t.Fatal("conflict created a project") + } +} + +func TestE2E_MiseRealGeneratedTasksUseLiveCommands(t *testing.T) { + mise := os.Getenv("ONE_TEST_MISE_BINARY") + if mise == "" { + t.Skip("set ONE_TEST_MISE_BINARY for real task integration") + } + tmp := t.TempDir() + isolateHome(t, tmp) + t.Setenv("ONE_RUNTIME", "") + root := bootstrapWorkspace(t, tmp, "generated-tasks") + useRealMise(t, root, mise) + if out, stderr, code := runBinaryIn(t, root, "add", "react-spa", "--name", "web", "-y"); code != 0 { + t.Fatalf("add: %d %s %s", code, out, stderr) + } + // User overrides have higher priority than generated defaults. This test + // exercises real mise without downloading Node or package managers. + if err := os.WriteFile(filepath.Join(root, "mise.toml"), []byte("[tools]\nnode = 'system'\npnpm = 'system'\n[env]\nTASK_DEFAULT = 'mise'\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "apps/web/.env"), []byte("TASK_DEFAULT=one\n"), 0o644); err != nil { + t.Fatal(err) + } + bin := filepath.Join(t.TempDir(), "one with spaces") + if runtime.GOOS == "windows" { + bin += ".exe" + } + raw, err := os.ReadFile(binaryPath(t)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bin, raw, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("ONE_BINARY_PATH", bin) + for _, command := range []string{"echo live-first", "echo live-second"} { + overrideDevCommand(t, root, "web", command) + cmd := exec.Command(mise, "run", "//apps/web:one:dev") + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), strings.TrimPrefix(command, "echo ")) { + t.Fatalf("generated task: %v %s", err, out) + } + } +} diff --git a/packages/cli/tests/e2e/mise_runtime_unix_test.go b/packages/cli/tests/e2e/mise_runtime_unix_test.go new file mode 100644 index 0000000..c29b2ee --- /dev/null +++ b/packages/cli/tests/e2e/mise_runtime_unix_test.go @@ -0,0 +1,130 @@ +//go:build unix + +package cli_test + +import ( + "bytes" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestE2E_MiseDevStopsBothProjects(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node required for process integration") + } + for _, mode := range []string{"interrupt", "child-failure"} { + t.Run(mode, func(t *testing.T) { + root := runtimeFixture(t) + // Both long-running fixtures execute Node. Give them valid Node + // workspace metadata so preparation can be exercised without Go. + manifest := filepath.Join(root, "one.manifest.json") + b, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest, []byte(strings.ReplaceAll(string(b), `"toolchain":"go"`, `"toolchain":"node"`)), 0o644); err != nil { + t.Fatal(err) + } + for path, body := range map[string]string{ + "package.json": `{"private":true,"packageManager":"npm@11.0.0","workspaces":["apps/*","services/*"]}`, + "services/api/package.json": `{"name":"api"}`, + } { + if err := os.WriteFile(filepath.Join(root, path), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if mise := os.Getenv("ONE_TEST_MISE_BINARY"); mise != "" { + useRealMise(t, root, mise) + } else { + installFakeMise(t) + } + if err := os.MkdirAll(filepath.Join(root, "node_modules"), 0o755); err != nil { + t.Fatal(err) + } + script := `const fs = require('node:fs'); +const net = require('node:net'); +const server = net.createServer(); +server.listen(0, '127.0.0.1', () => fs.writeFileSync('ready', String(server.address().port))); +setInterval(() => { if (fs.existsSync('fail')) process.exit(7); }, 20); +` + for _, rel := range []string{"apps/web", "services/api"} { + if err := os.WriteFile(filepath.Join(root, rel, "dev.cjs"), []byte(script), 0o644); err != nil { + t.Fatal(err) + } + } + cmd := exec.Command(binaryPath(t), "dev") + cmd.Dir = root + cmd.Env = os.Environ() + var log bytes.Buffer + cmd.Stdout, cmd.Stderr = &log, &log + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + finished := false + defer func() { + if !finished { + _ = cmd.Process.Signal(syscall.SIGTERM) + select { + case <-done: + case <-time.After(8 * time.Second): + _ = cmd.Process.Kill() + <-done + } + } + }() + ports := []string{} + deadline := time.Now().Add(10 * time.Second) + for _, rel := range []string{"apps/web", "services/api"} { + for { + if raw, err := os.ReadFile(filepath.Join(root, rel, "ready")); err == nil { + ports = append(ports, strings.TrimSpace(string(raw))) + break + } + select { + case err := <-done: + finished = true + t.Fatalf("dev stopped before ready: %v\n%s", err, log.String()) + default: + } + if time.Now().After(deadline) { + t.Fatal("dev did not start both projects") + } + time.Sleep(20 * time.Millisecond) + } + } + if mode == "interrupt" { + if err := cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + } else { + if err := os.WriteFile(filepath.Join(root, "apps/web/fail"), nil, 0o644); err != nil { + t.Fatal(err) + } + } + select { + case err := <-done: + finished = true + if err == nil { + t.Fatalf("expected interrupted or failed exit\n%s", log.String()) + } + case <-time.After(8 * time.Second): + t.Fatal("dev did not stop") + } + for _, port := range ports { + conn, err := net.DialTimeout("tcp", "127.0.0.1:"+port, 250*time.Millisecond) + if err == nil { + conn.Close() + t.Errorf("project still listening on %s after dev stopped", port) + } + } + }) + } +} diff --git a/packages/cli/tests/e2e/snapshot_e2e_create_test.go b/packages/cli/tests/e2e/snapshot_e2e_create_test.go index 9b1d648..15a655f 100644 --- a/packages/cli/tests/e2e/snapshot_e2e_create_test.go +++ b/packages/cli/tests/e2e/snapshot_e2e_create_test.go @@ -40,10 +40,12 @@ var expectedScaffoldPaths = []string{ "apps", "services", "packages", - "package.json", - "pnpm-workspace.yaml", "one.manifest.json", - "commitlint.config.js", + ".mise/conf.d/one.toml", + "hk.pkl", + ".config/one/hk.pkl", + ".git/hooks/pre-commit", + ".git/hooks/commit-msg", } func TestSnapshot_E2E_Create_Default(t *testing.T) { @@ -93,15 +95,15 @@ func TestSnapshot_E2E_Create_NameOverride(t *testing.T) { t.Errorf("project_name override: want %q, got %v", "custom-name", got["project_name"]) } - // Workspace package.json should reflect the override too. - pkg, err := os.ReadFile(filepath.Join(target, "package.json")) - if err != nil { - t.Fatalf("read package.json: %v", err) + // The language-neutral workspace keeps its name in the manifest. + mf := readManifest(t, target) + if mf["workspace"].(map[string]any)["name"] != "custom-name" { + t.Fatal("workspace name override was lost") } - pkgMap := mustParseJSON(t, string(pkg)) - if pkgMap["name"] != "custom-name" { - t.Errorf("package.json name: want %q, got %v", "custom-name", pkgMap["name"]) + if fileExists(t, filepath.Join(target, "package.json")) { + t.Fatal("empty workspace unexpectedly initialized Node") } + } func TestSnapshot_E2E_Create_NonEmptyTargetFails(t *testing.T) { diff --git a/packages/cli/tests/e2e/snapshot_e2e_dev_test.go b/packages/cli/tests/e2e/snapshot_e2e_dev_test.go index 6a5cc6e..ee5cf2c 100644 --- a/packages/cli/tests/e2e/snapshot_e2e_dev_test.go +++ b/packages/cli/tests/e2e/snapshot_e2e_dev_test.go @@ -77,6 +77,26 @@ func TestSnapshot_E2E_DevFromManifest(t *testing.T) { // scaffolded default (`go run ./cmd/server`) would block forever in // the test environment. overrideDevCommand(t, ws, "api", "echo built-in-supervisor-works") + // Keep dependency preparation real, but use a dependency-free module so + // this supervisor smoke test never installs the API template's toolchain. + apiDir := filepath.Join(ws, "services", "api") + if err := os.RemoveAll(apiDir); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(apiDir, 0o755); err != nil { + t.Fatal(err) + } + for file, content := range map[string]string{ + "go.work": "go 1.25.0\nuse ./services/api\n", + "services/api/go.mod": "module example.com/api\ngo 1.25.0\n", + "services/api/main.go": "package main\nfunc main(){}\n", + } { + if err := os.WriteFile(filepath.Join(ws, file), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + t.Setenv("GOTOOLCHAIN", "local") + t.Setenv("GOPROXY", "off") stdout, stderr, code := runBinaryIn(t, ws, "dev", "api", "-o", "json") if code != 0 { diff --git a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go b/packages/cli/tests/e2e/snapshot_e2e_ux_test.go index ad1605d..6204be6 100644 --- a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go +++ b/packages/cli/tests/e2e/snapshot_e2e_ux_test.go @@ -1,7 +1,6 @@ package cli_test import ( - "fmt" "os" "path/filepath" "reflect" @@ -21,7 +20,7 @@ func TestSnapshot_E2E_CreateDailyText(t *testing.T) { if code != 0 { t.Fatalf("create text flow failed: exit=%d stderr=%q", code, stderr) } - want := fmt.Sprintf("✓ 工作区已创建:demo\n 位置:%s\n 包管理器:pnpm\n 环境变量来源:本地 .env 文件\n 本地开发:one dev\n\n下一步:\n cd %s\n one add\n", target, target) + want := "✓ 工作区已创建:demo\n 位置:~/demo\n 环境变量来源:本地 .env 文件\n 本地开发:one dev\n\n下一步:\n cd demo\n one add\n" if stdout != want { t.Fatalf("unexpected create success text:\n--- want\n%s--- got\n%s", want, stdout) } @@ -137,13 +136,16 @@ func TestSnapshot_E2E_WorkspaceOverviewAndDeferredDeployment(t *testing.T) { t.Fatalf("unexpected project summary: %v", summary) } + savedPath := os.Getenv("PATH") + t.Setenv("PATH", t.TempDir()) _, stderr, code = runBinaryIn(t, ws, "dev", "web", "-o", "json") + t.Setenv("PATH", savedPath) if code == 0 { - t.Fatal("non-interactive dev should report missing Node dependencies") + t.Fatal("dev should report that the native package manager is unavailable") } devErr := mustParseJSON(t, firstJSONLine(stderr)) - if devErr["error"].(map[string]any)["code"] != "DEPENDENCIES_NOT_INSTALLED" { - t.Fatalf("unexpected missing-dependencies error: %v", devErr) + if devErr["error"].(map[string]any)["code"] != "RUN_COMMAND_NOT_FOUND" { + t.Fatalf("unexpected missing-tool error: %v", devErr) } _, stderr, code = runBinaryIn(t, ws, "deploy", "web", "--provider", "aws-s3", "-o", "json") diff --git a/packages/cli/tools/sync-mise/main.go b/packages/cli/tools/sync-mise/main.go new file mode 100644 index 0000000..d0100dd --- /dev/null +++ b/packages/cli/tools/sync-mise/main.go @@ -0,0 +1,184 @@ +// Command sync-mise fetches verified release archives at build time. The runtime +// adapter embeds one archive per target and never downloads mise at runtime. +package main + +import ( + "context" + "crypto/sha256" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "path/filepath" + "runtime" + "time" + + "github.com/gofrs/flock" + "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/runtime/mise/miserelease" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" +) + +const ( + maxArchiveSize = 256 << 20 + maxDownloadAttempts = 4 +) + +func main() { + all := flag.Bool("all", false, "prepare archives for all five One release targets") + flag.Parse() + if err := run(*all); err != nil { + fmt.Fprintln(os.Stderr, "sync-mise:", err) + os.Exit(1) + } +} + +func run(all bool) error { + root, err := os.Getwd() + if err != nil { + return err + } + for { + if _, err := os.Stat(filepath.Join(root, "go.work")); err == nil { + break + } + parent := filepath.Dir(root) + if parent == root { + return fmt.Errorf("go.work not found in current directory or its parents") + } + root = parent + } + targets := [][2]string{{runtime.GOOS, runtime.GOARCH}} + if all { + targets = [][2]string{{"linux", "amd64"}, {"linux", "arm64"}, {"darwin", "amd64"}, {"darwin", "arm64"}, {"windows", "amd64"}} + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + client := &http.Client{Timeout: 10 * time.Minute} + for _, target := range targets { + a, err := miserelease.ForPlatform(target[0], target[1]) + if err != nil { + return err + } + path := filepath.Join(root, "packages/cli/internal/adapters/runtime/mise/assets", "mise-"+target[0]+"-"+target[1]+"."+a.Format) + if err := syncArchive(ctx, client, miserelease.BaseURL+"/"+a.Filename(), path, a.ArchiveSHA256); err != nil { + return err + } + fmt.Printf("mise %s: %s/%s verified\n", miserelease.Version, target[0], target[1]) + } + return nil +} + +func syncArchive(ctx context.Context, client *http.Client, url, path, digest string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + lock := flock.New(path + ".lock") + locked, err := lock.TryLockContext(ctx, 100*time.Millisecond) + if err != nil { + return err + } + if !locked { + return fmt.Errorf("could not lock %s", path) + } + defer lock.Unlock() + if validArchive(path, digest) { + return nil + } + fmt.Fprintf(os.Stderr, "Fetching build resource %s\n", filepath.Base(path)) + for attempt := 1; ; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + retry, err := downloadArchive(ctx, client, url, path, digest) + if err == nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if !retry { + return err + } + if attempt == maxDownloadAttempts { + return fmt.Errorf("mise build resource failed after %d attempts: %w", attempt, err) + } + delay := time.Second << (attempt - 1) + fmt.Fprintf(os.Stderr, "Fetching %s failed: %v; retrying in %s (attempt %d/%d)\n", filepath.Base(path), err, delay, attempt+1, maxDownloadAttempts) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + } +} + +// Every attempt uses a fresh temporary file. Only transient download failures +// are retried; verification and local filesystem errors remain fatal. +func downloadArchive(ctx context.Context, client *http.Client, url, path, digest string) (retry bool, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + req.Header.Set("User-Agent", "one-cli/build-mise") + res, err := client.Do(req) + if err != nil { + return true, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + retry := res.StatusCode == http.StatusRequestTimeout || res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 && res.StatusCode < 600 + return retry, fmt.Errorf("mise build resource returned HTTP %d", res.StatusCode) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".fetch-*") + if err != nil { + return false, err + } + defer os.Remove(tmp.Name()) + hash := sha256.New() + body := &archiveReader{Reader: res.Body} + n, copyErr := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(body, maxArchiveSize+1)) + closeErr := tmp.Close() + if copyErr != nil { + return body.err != nil, copyErr + } + if closeErr != nil { + return false, closeErr + } + if n > maxArchiveSize { + return false, fmt.Errorf("mise build resource exceeds size limit") + } + if fmt.Sprintf("%x", hash.Sum(nil)) != digest { + return false, fmt.Errorf("mise archive SHA256 mismatch: %s", filepath.Base(path)) + } + return false, fsutil.ReplaceFile(tmp.Name(), path) +} + +type archiveReader struct { + io.Reader + err error +} + +func (r *archiveReader) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + if err != nil && err != io.EOF { + r.err = err + } + return n, err +} + +func validArchive(path, digest string) bool { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > maxArchiveSize { + return false + } + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + hash := sha256.New() + n, err := io.Copy(hash, io.LimitReader(f, maxArchiveSize+1)) + return err == nil && n <= maxArchiveSize && fmt.Sprintf("%x", hash.Sum(nil)) == digest +} diff --git a/packages/cli/tools/sync-mise/main_test.go b/packages/cli/tools/sync-mise/main_test.go new file mode 100644 index 0000000..52b0949 --- /dev/null +++ b/packages/cli/tools/sync-mise/main_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestBuildResourceVerificationAndCache(t *testing.T) { + archive := []byte("pinned archive") + digest := fmt.Sprintf("%x", sha256.Sum256(archive)) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = w.Write(archive) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "assets", "mise.tar.gz") + for range 2 { + if err := syncArchive(context.Background(), server.Client(), server.URL, path, digest); err != nil { + t.Fatal(err) + } + } + if requests.Load() != 1 { + t.Fatal("valid asset was downloaded again") + } + if err := os.WriteFile(path, []byte("previous file"), 0o644); err != nil { + t.Fatal(err) + } + if err := syncArchive(context.Background(), server.Client(), server.URL, path, "wrong-digest"); err == nil { + t.Fatal("accepted mismatched archive") + } + if requests.Load() != 2 { + t.Fatal("checksum mismatch was retried") + } + got, err := os.ReadFile(path) + if err != nil || string(got) != "previous file" { + t.Fatalf("previous resource replaced: %q %v", got, err) + } + if err := syncArchive(context.Background(), server.Client(), server.URL, path, digest); err != nil { + t.Fatal(err) + } + server.Close() + if err := syncArchive(context.Background(), server.Client(), server.URL, path, digest); err != nil { + t.Fatalf("offline build cache: %v", err) + } + leaked, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".fetch-*")) + if err != nil || len(leaked) != 0 { + t.Fatalf("temporary resources: %v %v", leaked, err) + } +} + +func TestBuildResourceHTTPErrorAndCancellation(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "mise.zip") + if err := syncArchive(context.Background(), server.Client(), server.URL, path, "digest"); err == nil || !strings.Contains(err.Error(), "HTTP 503") { + t.Fatalf("HTTP error lost: %v", err) + } + if requests.Load() != maxDownloadAttempts { + t.Fatalf("requests = %d, want %d", requests.Load(), maxDownloadAttempts) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := syncArchive(ctx, server.Client(), server.URL, path, "digest"); err == nil { + t.Fatal("cancellation ignored") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("failed fetch published an asset") + } +} + +func TestBuildResourceRetriesTransientFailures(t *testing.T) { + for _, status := range []int{http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusBadGateway, http.StatusGatewayTimeout, http.StatusOK} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + t.Parallel() + archive := []byte("verified archive") + digest := fmt.Sprintf("%x", sha256.Sum256(archive)) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) == 1 { + if status == http.StatusOK { + // A dropped connection must discard the partial download. + w.Header().Set("Content-Length", "100") + } + w.WriteHeader(status) + _, _ = w.Write([]byte("partial archive")) + return + } + _, _ = w.Write(archive) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "mise.tar.gz") + if err := syncArchive(context.Background(), server.Client(), server.URL, path, digest); err != nil { + t.Fatal(err) + } + if requests.Load() != 2 || !validArchive(path, digest) { + t.Fatalf("retry did not publish verified archive: requests=%d", requests.Load()) + } + leaked, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".fetch-*")) + if err != nil || len(leaked) != 0 { + t.Fatalf("temporary resources: %v %v", leaked, err) + } + }) + } +} + +func TestBuildResourcePermanentHTTPError(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "mise.zip") + if err := os.WriteFile(path, []byte("previous file"), 0o644); err != nil { + t.Fatal(err) + } + err := syncArchive(context.Background(), server.Client(), server.URL, path, "digest") + if err == nil || !strings.Contains(err.Error(), "HTTP 404") || requests.Load() != 1 { + t.Fatalf("permanent error: requests=%d, err=%v", requests.Load(), err) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != "previous file" { + t.Fatalf("previous resource replaced: %q %v", got, err) + } +} + +func TestBuildResourceCancelsRetryWait(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusGatewayTimeout) + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + err := syncArchive(ctx, server.Client(), server.URL, filepath.Join(t.TempDir(), "mise.zip"), "digest") + if !errors.Is(err, context.DeadlineExceeded) || requests.Load() != 1 { + t.Fatalf("cancellation: requests=%d, err=%v", requests.Load(), err) + } +} diff --git a/packages/cli/tools/verify-help/main.go b/packages/cli/tools/verify-help/main.go index 0e3098c..045610f 100644 --- a/packages/cli/tools/verify-help/main.go +++ b/packages/cli/tools/verify-help/main.go @@ -266,6 +266,11 @@ func scanInvocations(root, owner *cobra.Command, field, text string) []string { // or just prose. Don't flag, just skip. continue } + if resolved.DisableFlagParsing { + // Passthrough commands such as one hk and one mise forward + // flags to the native tool instead of registering them in Cobra. + continue + } // Extract --flag tokens from the FULL line so we catch flags // even after the command path. diff --git a/packages/cli/tools/verify-help/main_test.go b/packages/cli/tools/verify-help/main_test.go new file mode 100644 index 0000000..57a1355 --- /dev/null +++ b/packages/cli/tools/verify-help/main_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestRegisteredHelp(t *testing.T) { + if problems := run(); len(problems) != 0 { + t.Fatal(strings.Join(problems, "\n")) + } +} + +func TestExampleFlagsRespectResolvedCommandParsing(t *testing.T) { + root := &cobra.Command{Use: "one"} + hk := &cobra.Command{Use: "hk", DisableFlagParsing: true} + configure := &cobra.Command{Use: "configure"} + configure.Flags().Bool("dry-run", false, "") + root.AddCommand(hk, configure) + for _, tt := range []struct { + name, example string + owner *cobra.Command + wantProblems int + }{ + {"native flag", "one configure --dry-run", configure, 0}, + {"unknown native flag", "one configure --typo", configure, 1}, + {"forwarded flag", "one hk check --all", hk, 0}, + {"forwarded flag in another command's help", "one hk check --all", configure, 0}, + {"native typo in passthrough help", "one configure --typo", hk, 1}, + } { + t.Run(tt.name, func(t *testing.T) { + problems := scanInvocations(root, tt.owner, "Example", tt.example) + if len(problems) != tt.wantProblems { + t.Fatalf("problems = %v, want %d", problems, tt.wantProblems) + } + }) + } +} diff --git a/packages/templates/astro-site/README.md b/packages/templates/astro-site/README.md index de06426..ac29106 100644 --- a/packages/templates/astro-site/README.md +++ b/packages/templates/astro-site/README.md @@ -74,7 +74,7 @@ export function getDemoPost() { ## 说明 -这是模板仓库,不内置项目级 Changesets、commitlint、Biome 配置或发布工作流。相关治理由 `one-cli` 在工作区根目录统一生成和维护。 +这是模板仓库,不内置项目级 Git hooks、Biome 配置或发布工作流。工作区治理由 `one-cli` 提供,提交检查使用工作区的 hk 配置;版本管理和发布流程由项目按需配置。 ## TypeScript 版本 diff --git a/packages/templates/go-api/go.sum.hbs b/packages/templates/go-api/go.sum.hbs new file mode 100644 index 0000000..5a83dcf --- /dev/null +++ b/packages/templates/go-api/go.sum.hbs @@ -0,0 +1,277 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY= +github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= +github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= +github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo= +github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI= +github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= +github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= +github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= +github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= +github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= +github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0= +github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q= +github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k= +github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM= +github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY= +github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI= +github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c= +github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc= +github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk= +github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI= +github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I= +github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE= +github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM= +github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= +github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= +github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= +github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= +github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= +github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= +github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0= +go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk= +golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4= +gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/packages/templates/nextjs-app/README.md b/packages/templates/nextjs-app/README.md index c7eec50..af16c9e 100644 --- a/packages/templates/nextjs-app/README.md +++ b/packages/templates/nextjs-app/README.md @@ -82,4 +82,4 @@ export function getHello() { ## 说明 -这是模板仓库,不内置项目级 Changesets、Biome、额外 docs 目录或发布流水线。相关治理由 `one-cli` 在工作区根目录统一生成和维护。 +这是模板仓库,不内置项目级 Biome、额外 docs 目录或发布流水线。工作区治理由 `one-cli` 提供;版本管理和发布流程由项目按需配置。 diff --git a/packages/templates/react-spa/README.md b/packages/templates/react-spa/README.md index e99dc50..1782e5e 100644 --- a/packages/templates/react-spa/README.md +++ b/packages/templates/react-spa/README.md @@ -101,4 +101,4 @@ const { data, isLoading } = useSWR(demoKey, getDemo); ## 说明 -这是模板仓库,不内置项目级 CI、Changesets、commitlint、Docker Compose 或发布流程。相关内容由 `one-cli` 在工作区层统一生成和治理。 +这是模板仓库,不内置项目级 CI、Git hooks、Docker Compose 或发布流程。工作区治理由 `one-cli` 提供,提交检查使用工作区的 hk 配置;版本管理和发布流程由项目按需配置。 diff --git a/third_party/mise/LICENSE b/third_party/mise/LICENSE new file mode 100644 index 0000000..5333824 --- /dev/null +++ b/third_party/mise/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jeff Dickey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.