diff --git a/.githooks/pre-commit b/.githooks/pre-commit index b4b6c57..dd46d58 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -7,7 +7,7 @@ cd "$repo_root" unstaged=$(git diff --name-only --ignore-submodules --) untracked=$(git ls-files --others --exclude-standard) if [ -n "$unstaged" ] || [ -n "$untracked" ]; then - printf '%s\n' "pre-commit: the working tree must match the staged snapshot before running task check." >&2 + printf '%s\n' "pre-commit: the working tree must match the staged snapshot before running mise run check." >&2 if [ -n "$unstaged" ]; then printf '%s\n%s\n' "Unstaged files:" "$unstaged" >&2 fi @@ -18,10 +18,10 @@ if [ -n "$unstaged" ] || [ -n "$untracked" ]; then exit 1 fi -if ! command -v task >/dev/null 2>&1; then - printf '%s\n' "pre-commit: go-task is required; install it or run 'git commit --no-verify' to bypass intentionally." >&2 +if ! command -v mise >/dev/null 2>&1; then + printf '%s\n' "pre-commit: mise is required; install it or run 'git commit --no-verify' to bypass intentionally." >&2 exit 1 fi -printf '%s\n' "pre-commit: running the PR gate (task check)" -exec task check +printf '%s\n' "pre-commit: running the PR gate (mise run check)" +exec mise run check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bc6209..bc2eb9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,12 @@ # PR-time test gate with native Linux, Windows, macOS, and Go race coverage. # -# Two PR jobs call the same subtasks used by the local `task check` entrypoint: -# - lint: `task check:static` covers documentation drift, Go vet/gofmt, and +# Two PR jobs call the same subtasks used by the local `mise run check` entrypoint: +# - lint: `mise run check:static` covers documentation drift, Go vet/gofmt, and # Dashboard lint/format. The whole bundled # tree (registry/_templates/_web) is gitignored and regenerated -# via `task sync-bundled` + `task sync-web`, both of which run as deps -# of `task vet` — so vet implicitly bootstraps the bundle here. -# - test: `task check:test` builds bin/one then runs Go + Dashboard tests. +# via `mise run sync-bundled` + `mise run sync-web`, both of which run as deps +# of `mise run vet` — so vet implicitly bootstraps the bundle here. +# - test: `mise run check:test` builds bin/one then runs Go + Dashboard tests. # Build is required because tests/e2e snapshot tests skip when # bin/one is missing. # Native Windows, macOS, and Go race jobs also run on every PR. The master @@ -36,7 +36,8 @@ permissions: contents: read env: - # GitHub-hosted runners are outside China; the default Taskfile proxy + MISE_TASK_CACHE_DIR: ${{ github.workspace }}/.cache/mise-task-artifacts + # GitHub-hosted runners are outside China; the default Go proxy # (goproxy.cn) is slower/flakier there and has caused CI download failures. GOPROXY: https://proxy.golang.org,direct GOTOOLCHAIN: local @@ -47,34 +48,18 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 + - name: Set up mise + uses: jdx/mise-action@v4 with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - with: - # The root packageManager pin drives version selection for every workspace app. - run_install: false + version: 2026.9.7 + experimental: true - - name: Set up Node - uses: actions/setup-node@v6 + - name: Restore task artifacts + uses: actions/cache@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Set up Task - uses: go-task/setup-task@v2 - with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules run: | @@ -101,41 +86,26 @@ jobs: fi - name: Run static gate - # Calls the same static subtask as local `task check`. - run: task check:static + # Calls the same static subtask as local `mise run check`. + run: mise run check:static test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 + - name: Set up mise + uses: jdx/mise-action@v4 with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + version: 2026.9.7 + experimental: true - - name: Set up Task - uses: go-task/setup-task@v2 + - name: Restore task artifacts + uses: actions/cache@v4 with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules run: | @@ -162,41 +132,29 @@ jobs: fi - name: Run test gate - # Calls the same test subtask as local `task check`. - run: task check:test + # Calls the same test subtask as local `mise run check`. + run: mise run check:test + + - name: Build template sources and generated projects + run: mise run check:templates test-windows: runs-on: windows-latest steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 + - name: Set up mise + uses: jdx/mise-action@v4 with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod + version: 2026.9.7 + experimental: true - - name: Set up pnpm - uses: pnpm/action-setup@v4 + - name: Restore task artifacts + uses: actions/cache@v4 with: - run_install: false - - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Set up Task - uses: go-task/setup-task@v2 - with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules shell: pwsh @@ -208,7 +166,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Run native Windows PR gate - run: task check + run: mise run check test-race: # Catch Go data races before merging, alongside the other required jobs. @@ -216,33 +174,18 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up pnpm - uses: pnpm/action-setup@v4 + - name: Set up mise + uses: jdx/mise-action@v4 with: - run_install: false + version: 2026.9.7 + experimental: true - - name: Set up Node - uses: actions/setup-node@v6 + - name: Restore task artifacts + uses: actions/cache@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Set up Task - uses: go-task/setup-task@v2 - with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules run: | @@ -270,7 +213,7 @@ jobs: - name: Run Go race tests # test:go builds the CLI for E2E coverage; Dashboard tests run in test. - run: task test:go + run: mise run test:go test-macos: # Run before merging so macOS path and process regressions block PRs. @@ -278,33 +221,18 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Set up Node - uses: actions/setup-node@v6 + - name: Set up mise + uses: jdx/mise-action@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + version: 2026.9.7 + experimental: true - - name: Set up Task - uses: go-task/setup-task@v2 + - name: Restore task artifacts + uses: actions/cache@v4 with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules run: | @@ -331,4 +259,4 @@ jobs: fi - name: Run test gate - run: task check:test + run: mise run check:test diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 2013879..340a512 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -25,6 +25,7 @@ permissions: contents: write env: + MISE_TASK_CACHE_DIR: ${{ github.workspace }}/.cache/mise-task-artifacts GOPROXY: https://proxy.golang.org,direct GOTOOLCHAIN: local @@ -253,37 +254,20 @@ jobs: RELEASE_SHA: ${{ steps.release.outputs.sha }} run: git checkout --detach "$RELEASE_SHA" - - name: Set up Go + - name: Set up mise if: steps.release.outputs.already_published != 'true' - uses: actions/setup-go@v7 + uses: jdx/mise-action@v4 with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up Task - if: steps.release.outputs.already_published != 'true' - uses: go-task/setup-task@v2 - with: - version: 3.53.1 - - - name: Set up pnpm - if: steps.release.outputs.already_published != 'true' - uses: pnpm/action-setup@v4 - with: - run_install: false + version: 2026.9.7 + experimental: true - - name: Set up Node + - name: Restore task artifacts if: steps.release.outputs.already_published != 'true' - uses: actions/setup-node@v6 + uses: actions/cache@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules without mutating the workspace if: steps.release.outputs.already_published != 'true' @@ -313,7 +297,7 @@ jobs: - name: Run complete release gate if: steps.release.outputs.already_published != 'true' - run: task pre-push + run: mise run pre-push - name: Verify release tree is clean if: steps.release.outputs.already_published != 'true' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a841faa..8365c7f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,6 +10,9 @@ on: - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "mise.toml" + - "packages/templates/registry.json" + - ".github/workflows/docs.yml" push: branches: [master] paths: @@ -17,6 +20,9 @@ on: - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "mise.toml" + - "packages/templates/registry.json" + - ".github/workflows/docs.yml" workflow_dispatch: permissions: @@ -29,20 +35,11 @@ jobs: - name: Check out uses: actions/checkout@v7 - - name: Set up pnpm - uses: pnpm/action-setup@v4 + - name: Set up mise + uses: jdx/mise-action@v4 with: - run_install: false - - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install docs deps - run: pnpm --filter one-cli-docs install --frozen-lockfile + version: 2026.9.7 + experimental: true - name: Build docs site - run: pnpm docs:build + run: mise run docs:build diff --git a/.gitignore b/.gitignore index 78fb23f..facc4a0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,15 +24,14 @@ go-dist/ # seo-cli local state .seo/ -# Bundled go:embed sources — regenerated by `task sync-bundled` (cheap -# cp from packages/templates/) and `task sync-web` +# Bundled go:embed sources — regenerated by `mise run sync-bundled` (cheap +# cp from packages/templates/) and `mise run sync-web` # (pnpm install + vite build of apps/dashboard/). Both run as deps of -# `task vet` / `test` / `build`, so the normal Taskfile flow always +# `mise run vet` / `test` / `build`, so the normal mise flow always # repopulates them. Fresh clone followed by `go build` directly will # fail until one of those tasks has run at least once. packages/cli/internal/resources/bundled/registry.json packages/cli/internal/resources/bundled/_templates/ -packages/cli/internal/resources/bundled/_skills/ packages/cli/internal/resources/bundled/_web/ # go-task @@ -53,3 +52,10 @@ bin-go/ .seo/patches/ # Legacy mise build resources from older checkouts; no longer used or embedded. /packages/cli/internal/adapters/runtime/mise/assets/ + +# mise task artifacts +.cache/mise-task-artifacts/ + +# Local template development; generated projects resolve their own workspace lock. +/packages/templates/**/pnpm-lock.yaml +/packages/templates/**/go.work.sum diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2ba6f10..766eb98 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -36,6 +36,7 @@ builds: ldflags: - -s -w - -X main.version={{.Version}} + - -X github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck.buildChannel=release flags: - -trimpath diff --git a/AGENTS.md b/AGENTS.md index 182cdfe..e6bb8fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,4 +32,4 @@ - 两份语言字典的键必须完整对应、译文非空、格式占位符兼容。新增翻译键时确认实际使用处能够解析。 - 根据改动覆盖两种语言的帮助、交互、错误或结果输出,并检查语言切换后没有旧语言残留。涉及持久化偏好的测试使用临时 HOME/config,避免修改开发者设置。 - 修改提示组件或 TUI 时,检查中文终端显示宽度、快捷键提示和文字对比度,并确认子进程原始输出仍被保留。 -- 文案变化需要审阅并更新相关帮助/输出快照;运行与改动相关的测试,并按仓库现有流程执行 `task check`。 +- 文案变化需要审阅并更新相关帮助/输出快照;运行与改动相关的测试,并按仓库现有流程执行 `mise run check`。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c5a03e3..b0291b0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,7 +36,7 @@ The built-in catalog currently contains 16 backends: | Domain | Backends | Count | | --- | --- | ---: | -| Environment | dotenv, infisical | 2 | +| Environment | infisical | 1 | | Deploy | aliyun-oss, tencent-cos, aws-s3, minio, rustfs, r2, kustomize, vercel, cloudflare, edgeone | 10 | | Container | docker, dockerhub, ghcr, acr | 4 | @@ -102,28 +102,27 @@ packages/cli/internal/ cli/ constructs adapters and injects all dependencies core/ backend/ backend identity, capabilities, profile type, and form policy - container/ OCI image workflow inputs, results, and registry value profile/ machine profile model and persistence primitives template/ template registry and selection model workspace/ manifest and workspace model ports/ - deploy/ deployment provider contract secrets/ command environment loader contract application/ - ci/ configure/ deployment/ execution/ workspace/ + configure/ execution/ workspace/ adapters/ - ci/ container/ deploy/ env/ shared/ toolchain/ + env/ shared/ toolchain/ runtime/ modules/ - container/ compiled Docker/OCI workflow and manifest publication creation/ Template-to-Workspace/Project materialisation development/ local development process orchestration - environment/ dotenv/Infisical workflows and workspace setup + environment/ Infisical variables and workspace bindings preset/ pure preset encoding, parsing, and resolution - skills/ bundled one-cli skill installation into agent user directories + tasks/ finite task plans and environment snapshots + miseconfig/ additive task and tool configuration + hooks/ staged-file checks and Git integration platform/ errors/ helpui/ i18n/ output/ preferences/ process/ prompt/ updatecheck/ resources/ - bundled/ embedded templates, registry, one-cli skill, and Dashboard + bundled/ embedded templates, registry, and Dashboard transport/ cobra/ one directory per command family http/ local Dashboard API @@ -173,7 +172,7 @@ Rules: 9. Cleanup is LIFO, idempotent, and best-effort. It covers local temporary resources; it does not pretend that an external cloud deployment is a reversible transaction. -10. Command, deploy, secrets, and CI provider sets are constructed explicitly. +10. Command, runtime, toolchain, and secrets provider sets are constructed explicitly. Provider packages do not register themselves through `init()`. 11. `internal/architecture/dependencies_test.go` enforces these boundaries for production Go files, including leaf-layer and transport/adapter rules. @@ -224,7 +223,7 @@ execution.Workspace root + manifest snapshot + project lookup `--> infer Project from the command working directory ``` -Deploy, container, dev, CI, configure, run, add, and environment commands use +Dev, configure, run, exec, add, and environment commands use this same boundary. Helpers receive the snapshot rather than a root string that would let them rediscover the workspace. A workflow that intentionally writes `one.manifest.json` must call `Workspace.Reload` before relying on the new @@ -234,15 +233,12 @@ outside a workspace renders help instead of producing a workspace error. Creation is one Template-driven compiled workflow: - `modules/creation.Service` is the single mutation boundary shared by ordinary - `one create`, `one create --preset`, `one add`, and first-deploy artifact - configuration; + `one create`, `one create --preset`, and `one add`; - workspace target revalidation, skeleton generation, Backend selection, environment preparation, Template rendering, manifest publication, project artifact generation and best-effort Git initialization stay behind that boundary; -- its private `syncProject` step owns container artifacts, the persisted dev - command, deploy configuration, and environment safety rules in dependency - order; +- its private `syncProject` step owns the persisted dev command and environment safety rules; - `modules/preset` is a pure plan format: it owns only preset codes, parsing, canonical encoding, registry resolution, and flag-conflict validation; - there is no top-level `modules/scaffold`: workspace-file generation is an @@ -254,7 +250,7 @@ Creation is one Template-driven compiled workflow: Dashboard Workspace reads and machine-local Profile selections enter through `application/workspace.Service`. The service owns Overview construction, -Backend validation, Project lookup, Template/deployment compatibility, and +Backend validation, Project lookup, Template compatibility, and Profile-binding policy, but has no manifest-publication capability. `one.manifest.json` is a read-only fact source for that projection service: the Project projection exposes its values, a SHA-256 revision, and resolved @@ -305,62 +301,24 @@ clients never submit an arbitrary filesystem root. Existing singular `/api/workspace/*` routes remain pinned to the launch Workspace for wire compatibility. -CI is an application workflow with a public provider compatibility seam: - -- `application/ci.Service` owns workspace/project selection, provider - validation, workflow path and enabled-state detection, enable/sync/disable - execution, result construction, and delete-confirmation enforcement; -- `PlanDisable` exposes only the enabled count needed for a prompt and carries - its workspace snapshot privately into `Disable`; `Disable` rechecks the - current workflow files so a stale plan cannot bypass confirmation; -- `pkg/ci.Provider` remains the stable out-of-tree rendering contract, while - bootstrap constructs the instance registry used by the service; -- Cobra owns positional/flag conflict parsing, confirmation prompts, and TTY - rendering. Workflow files remain the CI state; the manifest is not mutated. - -Deployment is the reference deep module for this flow: - -- `application/deployment` owns configured-target discovery, project/backend - target planning, template compatibility, environment validation and - overrides, profile resolution, secret injection ordering, pre-deploy build - ordering, provider dispatch, and result publication; -- target discovery and compatibility helpers stay package-private; transports - enter through the `PlanTargets` and `Execute` workflow surfaces instead of - assembling deployment policy from low-level functions; -- `ports/deploy.Provider` and `ports/deploy.Builder` are the narrow execution - seams used by the workflow; -- deploy adapters own vendor commands and the local project build process; -- Cobra consumes the application's ready/setup/choose-project plan and owns - only flag/argument parsing, interactive project/backend/profile choices, - progress presentation, and dry-run/result rendering. - -This boundary is intentional: adding another deployment transport must be able -to reuse the same workflow without importing Cobra, while adding another deploy -backend must not modify the workflow. - -Container is a vertical compiled module rather than a provider ecosystem: - -- `docker`, `dockerhub`, `ghcr`, and `acr` remain four user-visible Backend - identities because they have different profile and registry-host policy; -- all four use one Docker/OCI execution implementation, so - `modules/container.Service` validates Catalog capabilities, resolves registry - profiles, invokes the Docker adapter, and publishes image/build state back to - the manifest; -- `core/container` contains only transport-neutral values and result envelopes; -- Cobra owns project selection, version prompts, flags, dry-run display, and - result rendering; -- there is deliberately no `container.Provider` or `ProviderRegistry`. A real - execution seam should be introduced only when a second independent engine - exists, not when another registry profile is added. +Finite workspace tasks are owned by `modules/tasks` and `modules/miseconfig`: + +- `core/workspace.DiscoverTasks` reads pnpm scripts and Go Taskfiles. Commands stay in their native project files. +- `modules/miseconfig` generates additive mise fragments, tool pins, local dependency edges, and explicit cache declarations. User overrides stay in `mise.toml`. +- `modules/tasks` validates selection and dependencies, freezes each project's environment before cache lookup, then invokes mise once for the finite graph. +- Hidden task adapters consume the same temporary context for cache fingerprints and execution. Context values never enter generated configuration or result envelopes. +- mise owns task concurrency and artifact storage. One reports overall success/failure and leaves unsupported per-task event state unknown. +- `one build` and `one run build` share this implementation. `one exec` handles arbitrary commands, while development keeps the existing terminal supervisor after finite prerequisite builds. +- GitHub Actions files stay repository-owned and call the ordinary `ci` aggregate. Hooks and preset remain supported. Environment is a vertical deep module because its two built-in backends are compiled implementation components rather than independently distributed plugins: - `modules/environment.Service` owns environment/backend resolution, profile - resolution, project/path targeting, set planning, get/list/set/pull, + resolution, project/path targeting, set planning, list/set/delete, explicit Dashboard reads, backend switching, manifest bookkeeping, and create-time environment setup; -- the module composes the concrete dotenv and Infisical adapters directly; +- the module composes the Infisical adapter directly; - create enters through `PrepareWorkspace`; Cobra does not sequence backend sync/bind functions, and the Infisical adapter exposes no no-op `Sync` API; - `env set` enters through `PlanSet` and `Set`; the workspace resolution carried @@ -444,9 +402,9 @@ view layer. `src/architecture/dependencies.test.ts` enforces these rules and rejects local TypeScript barrel entrypoints so imports stay directly analyzable. -Repository verification has one public contract: `task check` (also exposed as +Repository verification has one public contract: `mise run check` (also exposed as root `pnpm check`). It composes `check:static` and `check:test`; CI runs those -same two subtasks in parallel. `task pre-push` adds Go race detection without +same two subtasks in parallel. `mise run pre-push` adds Go race detection without creating a separate definition of the PR gate. Node dependency resolution is likewise repository-owned: root @@ -460,14 +418,14 @@ remain independently installable after leaving this repository. Internal migration must preserve: -- current command names, flags, help contracts, and exit behavior; +- command exit behavior, except for explicitly approved command removals and renames; - `one.manifest.json` v1; - `~/.config/one/config.json` and `credentials.json` v1; - the legacy profile resolution order, extended ahead of it by optional Project+Environment and Workspace+Environment bindings from the additive machine-local `profile-bindings.json` v1 store; - structured success envelopes and `one-cli/error/v1` error codes; -- existing public packages under `packages/cli/pkg`. +- remaining public toolchain packages under `packages/cli/pkg`; the CI rendering contract has been removed. New HTTP endpoints may be added. Existing read and Profile-management payloads remain compatible; historical Dashboard routes that wrote a repository are a diff --git a/CHANGELOG.md b/CHANGELOG.md index 1348eaf..3be4ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed — 精简 One CLI skill 与显式安装 - -- `one skills install` 安装单个内置 `one-cli` skill:遵循 One Workspace Convention,并通过当前 CLI 的 help 查询命令。日常 CLI 升级无需同步更新 skill。 -- 支持交互选择、`--agent` / `-a` 和 `--yes`;非交互安装到检测到的 Agent,无目标时提示显式选择。支持 JSON / YAML / text 输出及离线安装。 -- skill 直接复制到 Agent 的用户级目录。重复安装替换 `one-cli` 目录;兼容旧符号链接,不修改旧共享 store 或其他 skills。 -- 普通与 preset 创建均生成简短的根 `AGENTS.md`,要求使用 skill,缺失时运行 `one skills install`。创建项目不自动安装 skill,`one add` 保留已有指引。 -- 旧的 `one-migrate`、详细 playbook、共享 store 安装器和公开 Go 包 `packages/cli/pkg/agentskills` 保持移除。旧机器上的其他安装不自动清理。 -- `create` / `add` 输出不恢复旧 `skills` / `ai_guides` 字段;模板仍跳过独立的 `CLAUDE.md`、`AGENTS.md` 和 `.one` 元数据。 +### Changed — 工作区任务统一到 mise / Workspace tasks use mise + +- `one exec` 执行任意命令,`one run` 执行具名任务;`one build` 复用同一任务服务。Node 使用 pnpm,Go 使用 Task,工作区依赖与实验性产物缓存由 mise 调度。 +- `one exec` runs arbitrary commands; `one run` executes named tasks, and `one build` shares the same service. pnpm and Task own project commands; mise owns workspace scheduling and experimental artifact caching. +- 项目环境在缓存查找前冻结并参与指纹;自定义构建需要显式缓存规则。开发进程保留现有终端管理,上游构建使用 mise。 +- Project environments are frozen before cache lookup and included in fingerprints. Custom builds need explicit cache declarations. Development keeps its terminal supervisor and uses mise for prerequisite builds. +- 删除 `one ci`、工作流生成器和现有 skills 功能;保留仓库 Actions、hooks 和 preset。仓库贡献入口改为 `mise run check`。 +- Removed `one ci`, workflow generation, and the current skills feature. Repository Actions, hooks, and presets remain. The contributor gate is now `mise run check`. ### Removed (BREAKING — `one serve` session token) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9c2a3c..242aead 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,47 +5,66 @@ ## 一次性环境 ```bash -brew install go go-task node # macOS;Linux 用 apt / dnf 类比 -npm i -g pnpm@10.14.0 # 与根 package.json 的 packageManager 一致 +brew install mise # macOS;其他系统先安装 mise git clone https://github.com/1cli-team/one-cli cd one-cli -task install # 打包 Dashboard + CLI,再创建当前平台的本地启动器 +mise trust # 信任当前 checkout 的根和项目配置 +mise install # 安装 mise.toml 固定的工具版本 +mise run install # 打包 Dashboard + CLI,再创建本地启动器 one --version # 验证装好 ``` -工具链:**Go 1.26+**、**pnpm 10.14.0**。Node 推荐使用 **24.x(至少 24.15.0)**,也支持 22.x(至少 22.22.2)或 26+;Dashboard 测试依赖的 jsdom 不再支持 Node 20。`go-task`(不是 GNU make)是任务总线,跨平台一致。 +工具版本以根 `mise.toml` 为准:Go 1.27.1、Node 24.15.0、pnpm 10.14.0、Task 3.53.1。工作区由 mise 调度,Go 子项目使用 Task,Node 项目使用 pnpm。 > **fresh-clone 提示**:`packages/cli/internal/resources/bundled/` 整个目录是 gitignore 的—— -> registry / templates / dashboard dist 都由 `task sync-bundled` + -> `task sync-web` 按需重建。这些任务作为 `task vet` / `test` / `build` 的依赖自动运行; -> mise 不再是内嵌资源,构建与普通测试不需要下载 mise。 -> 第一次 `task install` 会触发 `pnpm install + vite build`,~30s;之后 -> task fingerprint 命中,几乎零成本。如果你直接跑 `go build` 而不走 Taskfile, +> registry / templates / dashboard dist 都由 `mise run sync-bundled` + +> `mise run sync-web` 按需重建。这些任务作为 `mise run vet` / `test` / `build` 的依赖自动运行; +> mise 程序不嵌入 One 发布文件;贡献者先安装 mise,再使用根任务。 +> 第一次 `mise run install` 会准备依赖并构建 Dashboard;之后输入未变时复用 Dashboard 产物缓存。 +> 如果你直接跑 `go build` 而不走 mise, > 会看到 `pattern all:_templates: no matching files found` 这种报错——跑一次 -> `task sync-bundled && task sync-web` 就好。 +> `mise run sync-bundled && mise run sync-web` 就好。 ## 日常开发 -所有命令都通过 Taskfile: +仓库本身也是 One CLI 工作区,`one.manifest.json` 登记了四个项目: + +| 项目 | 目录 | 用途 | +|---|---|---| +| `dashboard` | `apps/dashboard` | React / Vite 管理界面 | +| `docs` | `apps/docs` | Next.js / Fumadocs 文档站 | +| `cli` | `packages/cli` | Go CLI 和公开 Go 包 | +| `kernel` | `packages/kernel` | 共享 Go 内核 | + +`packages/templates` 和测试 fixture 是源码素材,不登记为项目。`packages/cli` 保留现有路径,因为公开 Go 包的 module path 已包含该目录。 + +完成首次安装后,可以直接用 One CLI 开发自身: + +```bash +one # 查看当前工作区 +one run # 查看根任务和项目任务 +one dev # Go Dashboard API + Vite UI,使用原有开发 fixture +one dev -p docs # 文档站:http://localhost:3000 +one build -p cli # 准备嵌入资源并构建 CLI +one test -p kernel # 测试共享 Go 内核 +one run check # 完整仓库检查;也可简写 one check +one serve # 在 Dashboard 中管理当前真实仓库 +``` + +单独运行 `one dev -p dashboard` 只启动 Vite 前端,需要在另一终端运行 `one dev -p cli` 提供 API;通常直接使用联合任务 `one dev`。`one serve` 管理当前真实仓库,开发 API 仍使用测试 fixture。构建、测试和开发不需要绑定 Infisical。 + +根 `mise.toml` 继续维护原生任务,供首次安装、CI 和 Git hooks 使用: ```bash -task --list # 看可用任务(这是真源) -pnpm install # 从根目录安装所有 Node workspace 依赖 -task dev # 同时启动 Go Dashboard API + Vite UI -task check # 在当前操作系统运行 monorepo 验证入口 -pnpm check # 根目录快捷入口,等价于 task check -task hooks:install # 为当前 checkout 启用提交前 PR gate -task build # 编译到 packages/cli/bin/one -task test # 全套 Go 测试 + race detector -task vet # go vet -task fmt # gofmt -task install # 打包 + 本地启动器(开发用;install-local 仍是兼容别名) -task pre-push # 推前必跑(含上面所有 + verify-docs) +mise run install-deps # 安装锁定的 Node workspace 依赖 +mise run check # 完整仓库检查 +mise run build # 编译到 packages/cli/bin/one +mise run test # Go race 测试 + Dashboard 测试 +mise run install # 打包并安装本地启动器,无需预先安装 one +mise run pre-push # 推送前检查,包含 race 测试 ``` -`build` / `test` / `vet` 都隐式依赖 `sync-bundled` + `sync-web`,所以你不用 -手动跑这两个——除非要让 gopls 立刻看到 `packages/templates/` 或 `apps/dashboard/` -的改动。 +项目任务来自各自的 `package.json` 或 `Taskfile.yml`,只在根 `mise.toml` 登记 `cli:build`、`dashboard:dev` 这样的任务入口,并通过 `dir` 指定子项目目录。根文件也声明 Dashboard、模板和嵌入资源的前置步骤,CLI 测试会先构建 E2E 使用的二进制。新增或修改任务目录后,运行 `one init mise` 同步这些受版本控制的配置。 ## 提交流程 @@ -53,18 +72,18 @@ task pre-push # 推前必跑(含上面所有 + verify-docs) 2. 改代码 + 测试 3. 提交:commit 消息走 [conventional commits](https://www.conventionalcommits.org/) (`feat:`、`fix:`、`chore:`、`docs:`、`test:`、`refactor:` 等)。仓库的 - pre-commit hook 会自动运行与 PR CI 相同的 `task check`;如果当前 checkout 尚未 - 启用 hook,先运行 `task hooks:install`。hook 会拒绝混合已暂存、未暂存或未跟踪的 + pre-commit hook 会自动运行与 PR CI 相同的 `mise run check`;如果当前 checkout 尚未 + 启用 hook,先运行 `mise run hooks:install`。hook 会拒绝混合已暂存、未暂存或未跟踪的 文件,确保本地检查的内容与即将提交、随后由 CI 检查的快照一致 -4. **必跑** `task pre-push` 全绿(包含 Go race detector) +4. **必跑** `mise run pre-push` 全绿(包含 Go race detector) 5. 推送 + 开 PR -PR CI 在 Linux 上并行执行 `task check:static`、`task check:test` 与 -`task test:go`(Go race detector),同时在 Windows 上执行 `task check`、 -macOS 上执行 `task check:test`。master 的保护规则要求 `lint`、`test`、 +PR CI 在 Linux 上并行执行 `mise run check:static`、`mise run check:test` 与 +`mise run test:go`(Go race detector),同时在 Windows 上执行 `mise run check`、 +macOS 上执行 `mise run check:test`。master 的保护规则要求 `lint`、`test`、 `test-windows`、`test-macos`、`test-race` 五项检查全部通过才能合并。 -本地 `task check` 和 `task pre-push` 只验证当前操作系统,不能代替其他平台的 -CI;`task pre-push` 额外运行 Go race detector。远端五项检查会在 PR、master +本地 `mise run check` 和 `mise run pre-push` 只验证当前操作系统,不能代替其他平台的 +CI;`mise run pre-push` 额外运行 Go race detector。远端五项检查会在 PR、master 推送和手动触发的工作流中运行。 ## 改不同部分的注意事项 @@ -72,7 +91,7 @@ CI;`task pre-push` 额外运行 Go race detector。远端五项检查会在 PR ### 改 Go 代码(`packages/cli/internal/` / `packages/cli/pkg/`) - 公开 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` 刷新文档 +- 加新错误码:在 `packages/cli/internal/platform/errors/codes.go` 注册 `Code` 常量 + `Codes` map 条目;测试会强制对应;改完跑 `mise run gen-error-codes` 刷新文档 ### mise 运行时 @@ -86,18 +105,18 @@ One 发布文件不包含 mise 程序或压缩包。实际运行时优先使用 ### 改 templates(`packages/templates//`) -- 模板会被 `go:embed` 进二进制(`task sync-bundled` 是同步入口,自动跑) +- 模板会被 `go:embed` 进二进制(`mise run sync-bundled` 是同步入口,自动跑) - 加新模板:在 `packages/templates/registry.json` 登记 + 加 `packages/templates//` 目录 ### 改 dashboard(`apps/dashboard/`,`one serve` 的 UI) - React + Vite,pnpm 管理 -- 前后端联调:在仓库根目录运行 `task dev`,打开 `http://localhost:5173/` -- `task dev` 的 Workspace/Project 数据来自仓库内固定 fixture;Profile 增删改查仍会 +- 前后端联调:在仓库根目录运行 `mise run dev`,打开 `http://localhost:5173/` +- `mise run dev` 的 Workspace/Project 数据来自仓库内固定 fixture;Profile 增删改查仍会 操作本机真实的 One 配置,Profile binding 也会真实写入但只关联 fixture Workspace - 本地开发:先在仓库根目录运行 `pnpm install`,再运行 `pnpm --filter one-serve-web dev` -- 静态检查:`task check:dashboard`;架构护栏和交互测试包含在 `task check:test` -- 改完后 `task vet` / `test` / `build` 会自动跑 `sync-web`(pnpm install + vite build) +- 静态检查:`mise run check:dashboard`;架构护栏和交互测试包含在 `mise run check:test` +- 改完后 `mise run vet` / `test` / `build` 会自动跑 `sync-web`(pnpm install + vite build) 并刷 `packages/cli/internal/resources/bundled/_web/` ### 改文档站(`apps/docs/`) @@ -105,23 +124,23 @@ One 发布文件不包含 mise 程序或压缩包。实际运行时优先使用 - 文档站是 Next.js + Fumadocs SSG - 本地预览:先在仓库根目录运行 `pnpm install`,再运行 `pnpm docs:dev` - 线上部署:Vercel 项目 Root Directory 指向 `apps/docs`,Output Directory 用 `dist`,域名绑定 `1cli.dev` -- `apps/docs/content/docs/reference/error-codes.md` **不要手工编辑**——跑 `task gen-error-codes` 重生成 +- `apps/docs/content/docs/reference/error-codes.md` **不要手工编辑**——跑 `mise run gen-error-codes` 重生成 - 新增页面要更新对应目录的 `meta.json`(sidebar 顺序) ### 改 install.sh(`apps/docs/public/install.sh`) -- 改完跑 `task test` —— `packages/cli/tests/e2e/install_sh_test.go` 会做静态检查(语法、必要 sentinels、wrap-in-main 不变量) +- 改完跑 `mise run test` —— `packages/cli/tests/e2e/install_sh_test.go` 会做静态检查(语法、必要 sentinels、wrap-in-main 不变量) ## 测试约定 ```bash -task test # 默认全套 +mise run test # 默认全套 (cd packages/cli && go test ./internal/foo) # 单个包 (cd packages/cli && go test -run TestX ./...) # 单个 test (cd packages/cli && UPDATE_SNAPSHOTS=1 go test ./tests/e2e) # 重生成 e2e snapshot fixtures ``` -E2E snapshot 测试位于 `packages/cli/tests/e2e/snapshot_e2e_*_test.go`,依赖 `packages/cli/bin/one` 存在 —— 跑 `task build` 之后再跑。 +E2E snapshot 测试位于 `packages/cli/tests/e2e/snapshot_e2e_*_test.go`,依赖 `packages/cli/bin/one` 存在 —— 跑 `mise run build` 之后再跑。 ## 发布流程 @@ -129,7 +148,7 @@ E2E snapshot 测试位于 `packages/cli/tests/e2e/snapshot_e2e_*_test.go`,依 1. 在 `master` 上运行工作流,选择 `patch`(默认)、`minor` 或 `major`。 2. 工作流从最高稳定 tag 自动计算下一版本,并把结果作为 `RELEASE_VERSION`;若最高 tag 位于 `master` 且尚未完成发布,则优先续跑该版本。 -3. 工作流执行完整 `task pre-push` 和 GoReleaser no-publish 预构建;验证通过后创建计算出的 tag,生成 5 个平台归档及 `checksums.txt`。 +3. 工作流执行完整 `mise run pre-push` 和 GoReleaser no-publish 预构建;验证通过后创建计算出的 tag,生成 5 个平台归档及 `checksums.txt`。 4. 只有 asset 集合完整时,GitHub Release 才会从 draft 转为公开发布。 不需要为了发布修改或提交任何版本文件,也不要手工推 tag。若发布在 tag 或 draft 创建后失败,修复问题后重新运行;工作流会自动识别安全的未完成 tag 并复用 draft。已经完整发布的版本不会被重复发布。 @@ -156,7 +175,7 @@ packages/cli/ # Go module(module path 含 /packages/cli 后 internal/ # 业务逻辑 pkg/ # 公开 Go API(semver 保护) internal/resources/bundled/ # go:embed 镜像,目录整个 gitignore, - # 由 task sync-bundled + sync-web 重建 + # 由 mise run sync-bundled + sync-web 重建 testdata/ # Go 测试 fixtures tools/ # 内部生成器 / 校验器 # gen-error-codes / verify-cli-references / verify-help @@ -164,7 +183,7 @@ packages/templates/ # 模板源 + registry.json(被 go:embed) apps/docs/ # 文档站 Next.js + Fumadocs apps/dashboard/ # `one serve` 用的 React + Vite UI(被 go:embed) .github/workflows/ # ci / cli / docs -Taskfile.yml / .goreleaser.yaml # 顶层编排(路径都按上面这套) +mise.toml / .goreleaser.yaml # 顶层编排(路径都按上面这套) pnpm-workspace.yaml # apps/* + packages/* DESIGN.md / apps/docs/design/ # 设计源 ``` @@ -173,7 +192,7 @@ DESIGN.md / apps/docs/design/ # 设计源 - 找 issues / discussions on GitHub - 看 [CLAUDE.md](./CLAUDE.md) 的"Don't"列表(避免常踩的坑) -- 查命令:`task --list` / `one --help` +- 查命令:`mise tasks ls` / `one --help` ## License diff --git a/README.md b/README.md index aa055e6..c1a1fad 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Create a workspace and add a project: one create my-app cd my-app one add react-spa --name web -one dev web +one dev -p web ``` That gives you a workspace, a first app, and a local way to run it. @@ -89,26 +89,19 @@ one add nestjs-api --name api |---|---| | `one create ` | Create an empty workspace | | `one add ` | Add another app, service, docs site, or library | -| `one dev [projects...]` | Run all or selected projects; native output for one task, TUI for multiple tasks | -| `one build [projects...]` | Build all or selected projects in dependency order; optional bounded concurrency | | `one env` | Review and manage environment variables | | `one login` | Sign in to Infisical with your browser | | `one serve` | Inspect workspaces, manage the current account and shared credentials | -| `one ci [enable\|sync\|disable]` | Optionally manage generated GitHub Actions workflows | +| `one run [task]` | Discover and execute workspace tasks through mise | +| `one exec -- ` | Execute a command with the selected project environment | + +`dev`, `build`, `test`, and `lint` are task names. They all use the same shorthand: `one ` → `one run `. For example, `one dev` runs `one run dev`, and `one build -p web` runs `one run build -p web`. Full command docs live at [1cli.dev](https://1cli.dev). ## Work With AI Assistants -Install the bundled `one-cli` skill for your coding agent: - -```bash -one skills install -``` - -Use `--agent ` to choose an agent directly, or `--yes` to install into every detected agent. Installation works offline and only writes the selected agents' user skills directories. - -New workspaces include an `AGENTS.md` asking agents to use this skill and run `one skills install` if it is missing. The [skill](./skills/one-cli/SKILL.md) stays small: follow [One Workspace Convention](https://github.com/1cli-team/one-workspace-convention), then consult `one --help` and the relevant command help. Ordinary CLI upgrades do not require reinstalling it. +New workspaces include bilingual `AGENTS.md` guidance. Agents can inspect `one run --list -o json`, preview tasks with `one run build --dry-run`, and consult command-specific help. You can ask an assistant for project-level changes in natural language, for example: @@ -122,11 +115,11 @@ The assistant can read `one.manifest.json` and project README files, then use On ## Local Settings -One CLI manages local dotenv and Infisical variables. Run `one login` to sign in with your browser; the single session is stored in the OS keyring, with no plaintext fallback. Use `one whoami` to inspect status and `one logout` to remove the local session. +One CLI manages variables in Infisical and injects them directly into commands. Workspace bindings live in the top-level manifest `env` field; `.env` files are not loaded or exported. Run `one login` to sign in with your browser; the single session is stored in the OS keyring, with no plaintext fallback. Use `one whoami` to inspect status and `one logout` to remove the local session. Run `one serve` for account settings, workspaces, and shared credentials. Workspace and project configuration changes share one reviewed, revision-checked Manifest draft. Remote variable edits take effect immediately; lists omit values and reveal/copy fetch plaintext only on demand. -Choose shared credential storage with `one env bind --global`. Agents discover environments and folders through `one env --global` and `one env list --global`, then execute with `one run --global --env dev --path /folder --keys KEY -- command`. Explicit scope and best-effort masking reduce accidental exposure; they do not isolate arbitrary programs running as the same OS user. Use least-privilege remote permissions. +Choose shared credential storage with `one env bind --global`. Agents discover environments and folders through `one env --global` and `one env list --global`, then execute with `one exec --global --env dev --path /folder --keys KEY -- command`. Explicit scope and best-effort masking reduce accidental exposure; they do not isolate arbitrary programs running as the same OS user. Use least-privilege remote permissions. ## Project Map @@ -140,23 +133,42 @@ If you want to work on One CLI itself, the repository is organized like this: | Path | Purpose | |---|---| -| `packages/cli` | The One CLI app | +| `one.manifest.json` | The four projects managed by One CLI itself | +| `packages/cli` | The One CLI app and its public Go packages | +| `packages/kernel` | Shared Go kernel | | `packages/templates` | Starters used by `one add` | -| `skills/one-cli` | Minimal workspace guidance installed by `one skills install` | +| `mise.toml` | Workspace scheduling, tools, and artifact cache declarations | | `apps/docs` | Documentation website | | `apps/dashboard` | Local workspace, account, and global-variable Dashboard opened by `one serve` | | `assets` | Brand assets, including the logo | -Common contributor commands: +This repository is also a One CLI workspace: `dashboard`, `docs`, `cli`, and `kernel`. Template directories under `packages/templates` and test fixtures are source assets, not registered projects. `packages/cli` keeps its existing location because it also exports Go packages with that module path. + +Bootstrap a fresh checkout without requiring an installed `one`: ```bash -pnpm install -task check -task build -task test -task verify-docs +mise trust +mise install +mise run install ``` +Then use the workspace commands: + +```bash +one # Inspect this workspace +one run # List root and project tasks +one dev # Dashboard API + Vite UI, using the development fixture +one dev -p docs # Documentation at http://localhost:3000 +one build -p cli # Prepare embedded resources and build the CLI +one test -p kernel # Test the shared Go kernel +one run check # The complete repository gate; one check is shorthand +one serve # Manage this repository in the Dashboard +``` + +`one dev -p dashboard` starts the Vite UI; run `one dev -p cli` in another terminal for its API, or use the combined `one dev` task. `one serve` opens this repository as a real workspace, while the contributor development API uses the existing test fixture. No Infisical binding is required to build, test, or start these projects. + +Root tasks remain native mise commands, so CI and first-time installation can still use `mise run build`, `mise run check`, and `mise run install`. Project tasks come from package scripts and Taskfiles. Only the root `mise.toml` is used: project tasks are namespaced as `cli:build` or `dashboard:dev` and select their directory with `dir`. CLI tasks declare their embedded-resource prerequisites there; CLI tests also build the binary used by E2E tests. Run `one init mise` after changing the project task catalogue to refresh the tracked task adapters. + Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request. ## Documentation @@ -171,35 +183,18 @@ Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request. MIT. -### Development and build terminals +### Tasks and development + +`one ` is shorthand for `one run `. Built-in commands take precedence; use `one run env` for a task that shares a built-in name. ```sh -one dev web api # Run a selected set of projects in parallel -one dev --select # Search and select projects interactively -one dev web # Keep the project's native colors, progress, and input -one dev web api --keep-going # Keep peers running if a project exits -one dev web api --ui=stream # Use continuous prefixed logs -one build web api --concurrency=4 # Build ready tasks concurrently, respecting local dependencies +one run test -p api # Explicit task entry +one test -p api # Same task through shorthand +one dev -p web -p api # Concurrent development services +one dev -p web --ui raw # Native terminal input for the selected service +one build -p web -p api --concurrency 4 ``` -`--ui=auto` uses a native terminal for one task and a TUI for multiple tasks. -Override it with `raw`, `tui`, or `stream`. TUI and raw require an interactive -terminal with text output; CI, pipes, and JSON/YAML output use streaming logs. -Structured results remain on stdout and task logs go to stderr. `--dry-run` -only prints the selected execution plan. - -In the TUI, use ↑/↓ to select a project, Enter to send it keyboard input, and -Ctrl+] to return to navigation. PgUp/PgDn scroll history, f resumes following, -/ searches projects, and h hides the project list. In dev, r restarts the selected -project and s stops it. Ctrl+C in navigation stops the session and its process -trees. Ctrl+C in input mode is sent to the selected application. By default any -dev process exiting stops the group; `--keep-going` keeps the other projects alive. - -Build concurrency defaults to 1. Selected local Node dependencies run first; -project selection does not implicitly add unselected dependencies. Failed builds -stop new scheduling, finish already running independent builds, and block tasks -that depend on the failure. Build sessions return to the shell automatically. - -Interactive task terminals currently support Unix (including Linux and macOS). -Windows supports native single-task output and streaming multiple tasks; auto -falls back to streaming until a ConPTY adapter is available. +All tasks use mise for scheduling, including development and user overrides. Multiple services use prefixed logs; Ctrl+C or a task failure stops the invocation and its child processes. Raw mode preserves terminal input and disables artifact caching. The previous development TUI and single-service restart controls have been removed. Structured results stay on stdout and child logs go to stderr. + +Build concurrency defaults to 1; development allocates it automatically. Local Node dependencies build before their consumers. `one run build --cache off --force` always executes the build. See the [task guide](apps/docs/content/docs/en/run.md) for configuration, cache declarations, and Actions examples. diff --git a/Taskfile.yml b/Taskfile.yml deleted file mode 100644 index f823e95..0000000 --- a/Taskfile.yml +++ /dev/null @@ -1,407 +0,0 @@ -version: '3' - -# Taskfile for one-cli. After the monorepo restructure the Go module lives at -# packages/cli/, the docs site at apps/docs/, and the `one serve` UI at -# apps/dashboard/. Run `task` (no args) for the menu of available targets. - -vars: - PKG: github.com/torchstellar-team/one-cli/packages/cli - CLI_DIR: packages/cli - KERNEL_DIR: packages/kernel - BIN_DIR: packages/cli/bin - GOEXE: - sh: go env GOEXE - BIN: 'packages/cli/bin/one{{.GOEXE}}' - GOPROXY: '{{.GOPROXY | default "https://goproxy.cn,direct"}}' - # Release builds pass RELEASE_VERSION explicitly. Ordinary local builds use - # a clearly non-release value so no tracked file can drift from a Git tag. - BUILD_VERSION: '{{.RELEASE_VERSION | default "0.0.0-dev"}}' - LDFLAGS: '-s -w -X main.version={{.BUILD_VERSION}}' - LOCAL_VERSION: - sh: | - base="${RELEASE_VERSION:-0.0.0-dev}" - commit=$(git rev-parse --short=7 HEAD 2>/dev/null || echo unknown) - dirty="" - if [ -n "$(git status --porcelain 2>/dev/null)" ]; then dirty=".dirty"; fi - echo "${base}-local.${commit}${dirty}" - LOCAL_LDFLAGS: '-s -w -X main.version={{.LOCAL_VERSION}}' - LOCAL_GO_PROCS: '{{.LOCAL_GO_PROCS | default "2"}}' - -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: - - task --list - - build: - desc: Build the local binary into packages/cli/bin/one - deps: [sync-bundled, sync-web] - status: - - test -f '{{.BIN}}' - - test "$({{.BIN}} --version)" = "{{.BUILD_VERSION}}" - cmds: - - 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/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 - cmds: - - go -C '{{.CLI_DIR}}' run ./tools/gen-error-codes - sources: - - 'packages/cli/internal/platform/errors/codes.go' - - 'packages/cli/tools/gen-error-codes/**/*.go' - generates: - - 'apps/docs/content/docs/zh/error-codes.md' - - verify-cli-references: - desc: Refuse `one ` references in docs that name unregistered commands - # The tool imports internal/bootstrap/cli → internal/resources/bundled, so it can't compile - # without the //go:embed sources being present. sync-bundled covers - # 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] - cmds: - - go -C '{{.CLI_DIR}}' run ./tools/verify-cli-references - sources: - - 'packages/cli/internal/**/*.go' - - 'packages/cli/tools/verify-cli-references/**/*.go' - - 'README.md' - - 'skills/**/*.md' - - 'CONTRIBUTING.md' - - 'apps/docs/content/docs/**/*.md' - - 'apps/docs/content/docs/**/*.mdx' - - 'packages/templates/*/README.md' - - 'packages/templates/*/README.md.hbs' - - verify-preset-codes: - desc: Lock the v1 preset code table (template + env) — codes are append-only and never re-used - # 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] - cmds: - - go -C '{{.CLI_DIR}}' test -count=1 -run 'TestTemplateCodesMatchGoldenAndRegistry|TestEnvCodesMatchGolden|TestGoldenSortedByCode' ./internal/modules/preset/... - sources: - - 'packages/cli/internal/modules/preset/**/*.go' - - 'packages/cli/testdata/preset/v1_codes.json' - - 'packages/templates/registry.json' - - verify-preset-vectors: - desc: Lock the v1 preset id encoding — Encode(spec) and Parse(id) roundtrip every frozen vector - deps: [sync-bundled, sync-web] - cmds: - - 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' - - verify-help: - desc: Refuse drift in cobra help text (rootHelp completeness + Example flag existence) - # The tool imports internal/bootstrap/cli → internal/resources/bundled, so it can't compile - # without the //go:embed sources being present. sync-bundled covers - # registry/_templates; sync-web covers _web. Mirrors the deps - # on verify-cli-references and verify-preset-codes. - deps: [sync-bundled, sync-web] - cmds: - - 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: - desc: All documentation drift checks (CLI references + help text + frontmatter presence + preset codes / vectors) - cmds: - - task: verify-cli-references - - task: verify-help - - task: verify-preset-codes - - task: verify-preset-vectors - - go run ./packages/cli/tools/repo-checks frontmatter - - verify-node-workspace: - desc: Keep application dependency resolution owned by the root pnpm workspace - cmds: - - go run ./packages/cli/tools/repo-checks node-workspace - - hooks:install: - desc: Enable the repository-managed Git hooks for this checkout - summary: | - Configures this checkout to load hooks from .githooks/. The pre-commit - hook runs `task check`, which is the same PR gate used by GitHub Actions. - cmds: - - git config --local core.hooksPath .githooks - - echo "Git pre-commit hook enabled (task check)" - - install: - aliases: [install-local] - desc: Build the CLI, install its launcher, and enable repository Git hooks - summary: | - One-shot developer install: builds the Dashboard, refreshes all bundled - assets, compiles the CLI with local version metadata, and creates a launcher - under ~/.local/bin pointing at the absolute binary in this checkout. - macOS/Linux use a one symlink. Windows prefers a one.exe symlink and - falls back to a one.cmd forwarding shim when symlinks are unavailable. - Both keep working when later builds replace the binary in place. - - `task install-local` remains a compatibility alias. - - Idempotent — safe to re-run after switching branches. - - Requires ~/.local/bin to be on PATH. Add to your shell rc if not: - export PATH="$HOME/.local/bin:$PATH" - deps: [build-local] - cmds: - - task: hooks:install - - go run ./packages/cli/tools/install-local "{{.BIN}}" - - build-local: - internal: true - deps: [sync-bundled, sync-web] - dir: '{{.CLI_DIR}}' - cmds: - - mkdir -p bin - - cmd: go build -p {{.LOCAL_GO_PROCS}} -trimpath -ldflags '{{.LOCAL_LDFLAGS}}' -o 'bin/one{{.GOEXE}}' ./cmd/one - env: - GOMAXPROCS: '{{.LOCAL_GO_PROCS}}' - - build-all: - desc: Cross-compile every supported platform under go-dist/ - deps: [sync-bundled, sync-web] - dir: '{{.CLI_DIR}}' - cmds: - - rm -rf ../../go-dist - - mkdir -p ../../go-dist - - for: ['darwin/amd64', 'darwin/arm64', 'linux/amd64', 'linux/arm64', 'windows/amd64'] - cmd: | - plat={{.ITEM}} - os=${plat%/*} - arch=${plat#*/} - out=../../go-dist/one-${os}-${arch} - [ "$os" = "windows" ] && out="${out}.exe" - echo "→ $out" - GOOS=$os GOARCH=$arch go build -trimpath -ldflags '{{.LDFLAGS}}' -o "$out" ./cmd/one - - check: - desc: Run the complete monorepo PR gate - summary: | - Single local and CI entrypoint for One CLI's Go workspace and embedded - Dashboard. Static checks and tests remain separate subtasks so CI can run - them in parallel while `task check` preserves the same contract locally. - cmds: - - task: check:static - - task: check:test - - check:static: - desc: Run documentation, Go, and Dashboard static checks - cmds: - - task: verify-node-workspace - - task: verify-docs - - task: vet - - task: fmt:check - - task: check:dashboard - - check:test: - desc: Build the binary and run Go plus Dashboard tests without race detection - cmds: - - task: test:go:plain - - task: test:dashboard - - check:dashboard: - desc: Run Dashboard lint and formatting checks - cmds: - - pnpm --dir apps/dashboard run check - - fmt:check: - desc: Check Go formatting without rewriting files - cmds: - - go run ./packages/cli/tools/repo-checks gofmt - - test: - desc: Run Go tests with race detector and Dashboard interaction tests - cmds: - - task: test:go - - task: test:dashboard - - test:go: - desc: Run kernel and CLI tests with race detector - deps: [build] - cmds: - - go test -race ./{{.KERNEL_DIR}}/... - - go test -race ./{{.CLI_DIR}}/... - - test:go:plain: - desc: Run kernel and CLI tests without race detector - deps: [build] - cmds: - - go test ./{{.KERNEL_DIR}}/... - - go test ./{{.CLI_DIR}}/... - - test:dashboard: - desc: Run Dashboard interaction tests - deps: [build-web] - cmds: - - pnpm --dir apps/dashboard run test - - test:snapshot: - desc: Run only the JSON-snapshot tests against testdata/reference - deps: [build] - dir: '{{.CLI_DIR}}' - cmds: - - go test -run Snapshot ./... - - pre-push: - desc: Run the PR gate plus Go race tests before pushing - summary: | - Runs `task check`, the exact PR CI contract, then repeats the Go suites - under the race detector. Dashboard architecture, lint, format, build, and - interaction tests are already covered by `task check`. - - Run before every push. Cheap enough (~10–30s on a warm cache) that you - shouldn't have to skip it. The first run on a fresh clone takes longer - because sync-web bootstraps the dashboard via pnpm install + vite build. - cmds: - - task: check - - task: test:go - - fmt: - desc: gofmt the codebase - cmds: - - go fmt ./{{.KERNEL_DIR}}/... - - go fmt ./{{.CLI_DIR}}/... - - vet: - desc: go vet kernel and CLI modules - deps: [sync-bundled, sync-web] - cmds: - - go vet ./{{.KERNEL_DIR}}/... - - go vet ./{{.CLI_DIR}}/... - - tidy: - desc: Tidy every Go workspace module - cmds: - - go -C {{.KERNEL_DIR}} mod tidy - - go -C {{.CLI_DIR}} mod tidy - - go work sync - - clean: - desc: Remove packages/cli/bin/ and go-dist/ - cmds: - - rm -rf {{.BIN_DIR}} go-dist - - dev: - desc: Run the Go Dashboard API and Vite UI together - summary: | - Starts the current Go source on http://127.0.0.1:5174 and the Vite - Dashboard on http://localhost:5173. Open that Vite URL directly. - - Workspace and Project data comes from the checked-in Dashboard fixture. - Login and global-variable operations use the real local Infisical session. - Workspace configuration is scoped to the fixture Workspace root. - - Both processes stop together when you press Ctrl-C. - deps: [sync-bundled, sync-web] - cmds: - - task --parallel --failfast --output prefixed dev:serve dev:dashboard - - dev:serve: - dir: '{{.CLI_DIR}}/testdata/dashboard-dev-workspace' - cmds: - - go run ../../cmd/one serve --port 5174 --open=false -o text - - dev:dashboard: - env: - VITE_DEV_API_TARGET: http://127.0.0.1:5174 - VITE_DEV_DATA_MODE: fixture-live-session - cmds: - - pnpm --filter one-serve-web dev - - build-web: - desc: Build the embedded `one serve` web UI (apps/dashboard/ → apps/dashboard/dist/) - summary: | - Builds the React UI shipped with `one serve` (sources at apps/dashboard/). - Output lands at apps/dashboard/dist/. The bundled mirror at - packages/cli/internal/resources/bundled/_web/ is refreshed by `task sync-web`, - which depends on this task. - - Idempotent: reruns cheaply when sources are unchanged (task hashes - sources/generates fingerprints). - - Requires Node + pnpm locally and in CI. Dependencies are resolved from - the repository root pnpm-lock.yaml; app-level lockfiles are forbidden. - Edit the UI → run `task sync-web` to rebuild the ignored embedded mirror. - cmds: - - pnpm --filter one-serve-web install --frozen-lockfile - - pnpm --dir apps/dashboard build - sources: - - 'apps/dashboard/src/**/*' - - 'apps/dashboard/index.html' - - 'apps/dashboard/package.json' - - 'apps/dashboard/vite.config.ts' - - 'apps/dashboard/tsconfig*.json' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - generates: - - 'apps/dashboard/dist/index.html' - - sync-bundled: - desc: Regenerate registry/_templates/_skills under packages/cli/internal/resources/bundled (go:embed sources) - run: once - summary: | - Go's embed cannot traverse `..` or follow symlinks, so we keep physical - copies of canonical assets under packages/cli/internal/resources/bundled. The - whole bundled tree is gitignored and regenerated on demand. - - This task handles the cheap copies: registry.json, _templates/, _skills/. - It runs implicitly as a dep of `vet` / `test` / `build`. The dashboard - build (sync-web) is separate but identically wired — both have to run - before any Go command will compile the bundled package. - - Direct `go build` / IDE-driven `gopls` won't run any task, so a fresh - clone needs `task sync-bundled && task sync-web` once before the Go - toolchain stops complaining about missing embed paths. - - Agent instruction files and .one/.agents directories are excluded from - the bundled templates. Dev-only go.mod files inside packages/templates// are stripped - from the bundled copy — their presence isolates each Go-based template - from the parent module during repository development, but go:embed - treats any directory containing go.mod as a separate module and - excludes its subtree, which would silently drop the go-api template - from the binary. - cmds: - - go run ./packages/cli/tools/sync-resources bundled - - sync-web: - desc: Rebuild the embedded `one serve` UI from apps/dashboard/ and refresh packages/cli/internal/resources/bundled/_web/ - run: once - summary: | - Requires Node + pnpm — both CI and local dev need them. _web/ is - gitignored; this task is the only path that creates it. Runs as a - dep of `vet` / `test` / `build`, so the normal Taskfile flow always - keeps it fresh. - - First run on a clean checkout takes 30–90s (pnpm install + vite build). - Subsequent runs hit the task-level sources/generates fingerprint and - are near-free unless you've edited apps/dashboard/. - deps: [build-web] - cmds: - - go run ./packages/cli/tools/sync-resources web diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 2fd4745..1ec7e14 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -43,7 +43,6 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", - "globals": "^17.12.0", "jsdom": "^30.1.1", "msw": "^2.15.0", "oxfmt": "^0.70.0", diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index e6b00a3..94e46b9 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,6 +1,5 @@ import type React from "react"; import { useMatch } from "react-router-dom"; -import { AppSidebar } from "@/components/AppSidebar"; import { TopBar } from "@/components/TopBar"; import { AppRoutes } from "@/router/routes"; import { cn } from "@/lib/utils"; @@ -10,7 +9,6 @@ export const App: React.FC = () => { return (
-
{ + return http.get(projectTemplatesKey); +} + +export async function createProject( + entryId: string, + input: { name: string; templateId: string }, +): Promise { + return http.post(`${workspaceBasePath(entryId)}/projects`, input, { timeout: 60000 }); +} diff --git a/apps/dashboard/src/api/services.ts b/apps/dashboard/src/api/services.ts new file mode 100644 index 0000000..3af2597 --- /dev/null +++ b/apps/dashboard/src/api/services.ts @@ -0,0 +1,36 @@ +import { workspaceBasePath } from "@/api/workspaces"; +import http from "@/lib/http"; +import type { DevService } from "@/types/api"; + +export function servicePath(project: string, entryId?: string) { + return `${workspaceBasePath(entryId)}/projects/${encodeURIComponent(project)}/service`; +} +export function controlService( + project: string, + entryId: string | undefined, + action: "start" | "stop" | "restart", + environment: string, +) { + return http.post(`${servicePath(project, entryId)}/${action}`, { environment }); +} +export function watchService( + project: string, + entryId: string | undefined, + receive: (state: DevService) => void, + connection: (connected: boolean) => void, +) { + const source = new EventSource(`/api${servicePath(project, entryId)}/events`); + source.onmessage = (event) => { + receive(JSON.parse(event.data) as DevService); + connection(true); + }; + source.onerror = () => connection(false); + return () => source.close(); +} + +export function servicesKey(entryId?: string) { + return `${workspaceBasePath(entryId)}/services`; +} +export function getServices(entryId?: string) { + return http.get<{ services: DevService[] }>(servicesKey(entryId)); +} diff --git a/apps/dashboard/src/api/workspace.ts b/apps/dashboard/src/api/workspace.ts index c3de392..35f5d19 100644 --- a/apps/dashboard/src/api/workspace.ts +++ b/apps/dashboard/src/api/workspace.ts @@ -34,25 +34,6 @@ export async function getWorkspaceEnvironment( return http.get(workspaceEnvironmentKey(entryId, environment)); } -export function workspaceEnvironmentBackendKey(entryId?: string, environment?: string): string { - return withEnvironment(`${workspaceBasePath(entryId)}/environment/backend`, environment); -} - -export async function switchWorkspaceEnvironmentBackend( - backend: string, - revision: string, - entryId?: string, - environment?: string, -): Promise { - return http.put( - workspaceEnvironmentBackendKey(entryId, environment), - { - backend, - revision, - }, - ); -} - export async function initializeWorkspaceEnvironmentBackend( entryId: string | undefined, environment: string, diff --git a/apps/dashboard/src/components/AppMenu.test.tsx b/apps/dashboard/src/components/AppMenu.test.tsx new file mode 100644 index 0000000..293b2b5 --- /dev/null +++ b/apps/dashboard/src/components/AppMenu.test.tsx @@ -0,0 +1,149 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { SWRConfig } from "swr"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { AppMenu } from "@/components/AppMenu"; +import { TopBar } from "@/components/TopBar"; +import { ThemeProvider } from "@/providers/ThemeProvider"; +import i18n from "@/lib/i18n"; +import { useLocaleStore } from "@/lib/stores/locale"; +import { useThemeStore } from "@/lib/stores/theme"; + +const workspaces = [ + { entryId: "alpha", name: "Alpha", root: "/workspaces/alpha", status: "ready", projectCount: 1 }, + { + entryId: "beta", + name: "Beta", + root: "/workspaces/beta", + status: "identity-conflict", + projectCount: 2, + }, +]; +const server = setupServer( + http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces })), + http.get("http://localhost/api/session", () => + HttpResponse.json({ session: { loggedIn: true, email: "test@example.com" } }), + ), +); +function LocationProbe() { + const location = useLocation(); + return ( + + {location.pathname} + {location.search} + + ); +} +function show(withHeader = false) { + return render( + new Map(), shouldRetryOnError: false }}> + + {withHeader ? : } + + + , + ); +} +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeEach(async () => { + await i18n.changeLanguage("en-US"); + useThemeStore.setState({ mode: "light" }); + useLocaleStore.setState({ mode: "auto", resolved: "en-US" }); +}); +afterEach(() => { + server.resetHandlers(); + localStorage.removeItem("app_theme_mode"); + localStorage.removeItem("app_locale_mode"); + document.documentElement.classList.remove("light", "dark"); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.style.colorScheme = ""; +}); +afterAll(() => server.close()); + +describe("brand navigation menu", () => { + it.each(["en-US", "zh-CN"])( + "opens from the brand and switches workspace with the environment preserved in %s", + async (locale) => { + await i18n.changeLanguage(locale); + const user = userEvent.setup(); + show(); + const trigger = screen.getByRole("button", { name: i18n.t("appMenu.label") }); + expect(screen.queryByRole("menu")).toBeNull(); + await user.click(trigger); + const menu = await screen.findByRole("menu", { name: i18n.t("appMenu.label") }); + const selected = await within(menu).findByRole("menuitem", { name: /Alpha/ }); + expect(selected.getAttribute("aria-current")).toBe("page"); + expect(within(menu).getByRole("menuitem", { name: /Beta/ }).getAttribute("href")).toBe( + "/workspace/beta?env=preview", + ); + expect(await within(menu).findByText("test@example.com")).toBeDefined(); + await user.click(within(menu).getByRole("menuitem", { name: /Beta/ })); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(screen.getByTestId("location").textContent).toBe("/workspace/beta?env=preview"); + }, + ); + + it("opens with the keyboard, dismisses with Escape and returns focus to the brand", async () => { + const user = userEvent.setup(); + show(); + const trigger = screen.getByRole("button", { name: "One CLI navigation menu" }); + trigger.focus(); + await user.keyboard("{ArrowDown}"); + await screen.findByRole("menu"); + await user.keyboard("{Home}"); + expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: "Home" })); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(document.activeElement).toBe(trigger); + }); + + it.each(["en-US", "zh-CN"])( + "switches theme and language from the header in %s", + async (locale) => { + await i18n.changeLanguage(locale); + const user = userEvent.setup(); + show(true); + const header = within(screen.getByRole("banner")); + await user.click(header.getByRole("button", { name: i18n.t("sidebar.themeToDark") })); + expect(useThemeStore.getState().mode).toBe("dark"); + expect(localStorage.getItem("app_theme_mode")).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + await user.click(header.getByRole("button", { name: i18n.t("sidebar.themeToLight") })); + expect(useThemeStore.getState().mode).toBe("light"); + const language = header.getByRole("button", { name: i18n.t("sidebar.language") }); + await user.click(language); + await user.click(await screen.findByRole("menuitemradio", { name: "中文" })); + expect(useLocaleStore.getState().mode).toBe("zh-CN"); + expect(localStorage.getItem("app_locale_mode")).toBe("zh-CN"); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(document.activeElement).toBe(language); + await user.click(header.getByRole("button", { name: i18n.t("appMenu.label") })); + const menu = await screen.findByRole("menu"); + expect(within(menu).queryByRole("menuitem", { name: i18n.t("sidebar.language") })).toBeNull(); + expect( + within(menu).queryByRole("menuitem", { name: i18n.t("sidebar.themeToDark") }), + ).toBeNull(); + }, + ); + + it("shows a retry action when the workspace registry fails", async () => { + server.use( + http.get("http://localhost/api/workspaces", () => new HttpResponse(null, { status: 500 })), + ); + const user = userEvent.setup(); + show(); + await user.click(screen.getByRole("button", { name: "One CLI navigation menu" })); + expect(await screen.findByRole("alert")).toBeDefined(); + server.use( + http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces })), + ); + await user.click(screen.getByRole("menuitem", { name: "Retry" })); + expect(await screen.findByRole("menuitem", { name: /Alpha/ })).toBeDefined(); + expect(screen.getByRole("menuitem", { name: "Home" }).getAttribute("href")).toBe( + "/?env=preview", + ); + }); +}); diff --git a/apps/dashboard/src/components/AppMenu.tsx b/apps/dashboard/src/components/AppMenu.tsx new file mode 100644 index 0000000..d366aef --- /dev/null +++ b/apps/dashboard/src/components/AppMenu.tsx @@ -0,0 +1,96 @@ +import { Boxes, ChevronDown, House, KeyRound, Settings2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + EnvironmentLink, + EnvironmentNavLink, +} from "@/features/environment-context/EnvironmentLink"; +import { SessionStatus } from "@/features/infisical-session/AccountSettings"; +import { WorkspaceMenu } from "@/features/workspace-registry/WorkspaceMenu"; +import { useThemeStore } from "@/lib/stores/theme"; + +export function AppMenu() { + const { t } = useTranslation(); + const { mode } = useThemeStore(); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/AppSidebar.tsx b/apps/dashboard/src/components/AppSidebar.tsx deleted file mode 100644 index 233d349..0000000 --- a/apps/dashboard/src/components/AppSidebar.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { KeyRound, House, Menu, MoonStar, Settings2, SunMedium } from "lucide-react"; -import type React from "react"; -import { useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { LanguageSwitcher } from "@/components/LanguageSwitcher"; -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { - EnvironmentLink, - EnvironmentNavLink, -} from "@/features/environment-context/EnvironmentLink"; -import { SessionStatus } from "@/features/infisical-session/AccountSettings"; -import { WorkspaceRail } from "@/features/workspace-registry/WorkspaceRail"; -import { useThemeStore } from "@/lib/stores/theme"; -import { cn } from "@/lib/utils"; - -const navItemClass = ({ isActive }: { isActive: boolean }) => - cn( - "relative flex min-h-10 items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors duration-150", - isActive - ? "bg-sidebar-active font-medium text-primary-text" - : "text-sidebar-muted hover:bg-muted hover:text-sidebar-foreground", - ); - -function SidebarContent({ onNavigate }: { onNavigate?: () => void }) { - const { mode, toggle } = useThemeStore(); - const { t } = useTranslation(); - return ( - -
{ - if ((event.target as HTMLElement).closest("a")) onNavigate?.(); - }} - > - - - One CLI - Dashboard - - - - - - -
- cn(navItemClass({ isActive }), "min-w-0 flex-1")} - > - - {t("sidebar.settings")} - - - - - - - - {mode === "light" ? t("sidebar.themeToDark") : t("sidebar.themeToLight")} - - -
-
-
- ); -} - -export const AppSidebar: React.FC = () => ( - -); - -export function MobileNavigation() { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const navigationRef = useRef(null); - return ( - - - - - { - event.preventDefault(); - navigationRef.current?.querySelector("a")?.focus(); - }} - side="left" - closeLabel={t("sidebar.closeNavigation")} - className="w-80 gap-0 rounded-none bg-sidebar [&>button]:top-3 [&>button]:right-2" - > - {t("sidebar.navigation")} - {t("workspaces.home.description")} - setOpen(false)} /> - - - ); -} diff --git a/apps/dashboard/src/components/ErrorBoundary.tsx b/apps/dashboard/src/components/ErrorBoundary.tsx index d86358f..119134f 100644 --- a/apps/dashboard/src/components/ErrorBoundary.tsx +++ b/apps/dashboard/src/components/ErrorBoundary.tsx @@ -140,20 +140,3 @@ const DefaultErrorFallback: React.FC<{ export const ErrorBoundary: React.FC = ({ children, ...props }) => { return {children}; }; - -// HOC 版本的 ErrorBoundary -export const withErrorBoundary =

( - Component: React.ComponentType

, - fallback?: React.ComponentType<{ error?: Error; resetError: () => void }>, -) => { - const WrappedComponent = (props: P) => ( - - - - ); - - WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`; - return WrappedComponent; -}; - -export default ErrorBoundary; diff --git a/apps/dashboard/src/components/LanguageSwitcher.tsx b/apps/dashboard/src/components/LanguageSwitcher.tsx index b734cc0..c3758ce 100644 --- a/apps/dashboard/src/components/LanguageSwitcher.tsx +++ b/apps/dashboard/src/components/LanguageSwitcher.tsx @@ -1,6 +1,4 @@ -// LanguageSwitcher: globe-icon dropdown with three options -// (auto / zh-CN / en-US). Mirrors the theme toggle's footprint in -// the sidebar — same Button size, same icon-only collapsed state. +// Language controls share the existing auto / zh-CN / en-US preference. // // Uses Radix DropdownMenu so the menu inherits the design system's // focus/keyboard behaviour for free. @@ -28,12 +26,8 @@ const OPTIONS: Option[] = [ { mode: "en-US", labelKey: "sidebar.languageEn" }, ]; -const MENU_WIDTH = 160; -const TRIGGER_WIDTH = 28; -const CENTERED_END_OFFSET = -(MENU_WIDTH - TRIGGER_WIDTH) / 2; - export function LanguageSwitcher({ showLabel = false }: { showLabel?: boolean }) { - const { mode, setMode } = useLocaleStore(); + const { mode } = useLocaleStore(); const { t } = useTranslation(); return ( @@ -41,7 +35,6 @@ export function LanguageSwitcher({ showLabel = false }: { showLabel?: boolean }) - setMode(value as LocaleMode)} - > - {OPTIONS.map((option) => ( - - {t(option.labelKey)} - - ))} - + ); } + +function LanguageMenuItems() { + const { mode, setMode } = useLocaleStore(); + const { t } = useTranslation(); + return ( + setMode(value as LocaleMode)} + aria-label={t("sidebar.language")} + className="space-y-1" + > + {OPTIONS.map((option) => ( + + {t(option.labelKey)} + + ))} + + ); +} diff --git a/apps/dashboard/src/components/TopBar.test.tsx b/apps/dashboard/src/components/TopBar.test.tsx index 5acc79b..d09cf21 100644 --- a/apps/dashboard/src/components/TopBar.test.tsx +++ b/apps/dashboard/src/components/TopBar.test.tsx @@ -56,7 +56,7 @@ describe("TopBar and manifest review", () => { revision: "sha256:base", before: JSON.stringify( { - domains: { env: "infisical" }, + env: { projectId: "old-project" }, projects: [{ name: "web", general: { buildVersion: "1.0.0" } }], }, null, @@ -64,7 +64,7 @@ describe("TopBar and manifest review", () => { ), after: JSON.stringify( { - domains: { env: "dotenv" }, + env: { projectId: "new-project" }, projects: [{ name: "web", general: { buildVersion: "2.0.0" } }], }, null, @@ -142,26 +142,26 @@ describe("TopBar and manifest review", () => { expect(screen.queryByRole("button", { name: /Save changes/ })).toBeNull(); }); - it("reviews and publishes a Workspace environment backend draft", async () => { + it("reviews and publishes a Workspace Infisical binding draft", async () => { useManifestDraftStore.getState().stageWorkspaceSection({ entryId: "demo-entry", revision: "sha256:base", section: "environment", - initial: { backend: "infisical" }, - next: { backend: "dotenv" }, - labels: { backend: "overview.workspaceEnv.backend" }, + initial: { backend: "infisical", projectId: "old-project" }, + next: { backend: "infisical", projectId: "new-project" }, + labels: { projectId: "global.project" }, }); const user = userEvent.setup(); renderManifestSaveControl("/workspace/demo-entry?env=dev"); await user.click(screen.getByRole("button", { name: "Save changes · 1" })); const dialog = await screen.findByRole("alertdialog"); - expect(await within(dialog).findByText(/"env": "infisical"/)).toBeDefined(); - expect(within(dialog).getByText(/"env": "dotenv"/)).toBeDefined(); + expect(await within(dialog).findByText(/"projectId": "old-project"/)).toBeDefined(); + expect(within(dialog).getByText(/"projectId": "new-project"/)).toBeDefined(); expect(previewManifestDraft).toHaveBeenCalledWith( { revision: "sha256:base", - workspace: { environment: { backend: "dotenv" } }, + workspace: { environment: { backend: "infisical", projectId: "new-project" } }, changes: [], }, "demo-entry", @@ -170,7 +170,11 @@ describe("TopBar and manifest review", () => { await user.click(within(dialog).getByRole("button", { name: "Save to manifest" })); await waitFor(() => expect(applyManifestDraft).toHaveBeenCalledWith( - { revision: "sha256:base", workspace: { environment: { backend: "dotenv" } }, changes: [] }, + { + revision: "sha256:base", + workspace: { environment: { backend: "infisical", projectId: "new-project" } }, + changes: [], + }, "demo-entry", ), ); @@ -188,9 +192,9 @@ describe("TopBar and manifest review", () => { entryId: "demo-entry", revision: "sha256:base", section: "environment", - initial: { backend: "infisical" }, - next: { backend: "dotenv" }, - labels: { backend: "overview.workspaceEnv.backend" }, + initial: { backend: "infisical", projectId: "old-project" }, + next: { backend: "infisical", projectId: "new-project" }, + labels: { projectId: "global.project" }, }); useManifestDraftStore.getState().stageSection({ entryId: "demo-entry", @@ -213,7 +217,7 @@ describe("TopBar and manifest review", () => { expect(applyManifestDraft).toHaveBeenCalledWith( { revision: "sha256:base", - workspace: { environment: { backend: "dotenv" } }, + workspace: { environment: { backend: "infisical", projectId: "new-project" } }, changes: [ { project: "web", @@ -225,7 +229,9 @@ describe("TopBar and manifest review", () => { ); const remaining = useManifestDraftStore.getState().drafts["demo-entry"]; expect(remaining.revision).toBe("sha256:base"); - expect(remaining.workspace).toEqual({ environment: { backend: "dotenv" } }); + expect(remaining.workspace).toEqual({ + environment: { backend: "infisical", projectId: "new-project" }, + }); expect(Object.keys(remaining.changes)).toEqual(["web"]); }); }); diff --git a/apps/dashboard/src/components/TopBar.tsx b/apps/dashboard/src/components/TopBar.tsx index 8d91cf8..7518416 100644 --- a/apps/dashboard/src/components/TopBar.tsx +++ b/apps/dashboard/src/components/TopBar.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { MoonStar, SunMedium } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useLocation, useMatch } from "react-router-dom"; import useSWR from "swr"; @@ -13,7 +14,10 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import { MobileNavigation } from "@/components/AppSidebar"; +import { AppMenu } from "@/components/AppMenu"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { Button } from "@/components/ui/button"; +import { useThemeStore } from "@/lib/stores/theme"; import type { SectionKey } from "@/types/api"; export { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; interface TopBarProps { @@ -22,10 +26,13 @@ interface TopBarProps { export const TopBar: React.FC = () => { const { t } = useTranslation(); + const { mode, toggle } = useThemeStore(); + const themeLabel = t(mode === "light" ? "sidebar.themeToDark" : "sidebar.themeToLight"); const sectionMatch = useMatch("/section/:domain/:backend"); const profileMatch = useMatch("/profile"); const settingsSectionMatch = useMatch("/settings/:domain/:backend"); const settingsMatch = useMatch("/settings"); + const templatesMatch = useMatch("/templates"); const globalMatch = useMatch("/global"); const workspaceMatch = useMatch("/workspace/:entryId"); @@ -33,9 +40,10 @@ export const TopBar: React.FC = () => { const detailMatch = settingsSectionMatch ?? sectionMatch; return ( -

+
- + +
+
+ + +
); }; diff --git a/apps/dashboard/src/components/ui/checkbox.tsx b/apps/dashboard/src/components/ui/checkbox.tsx deleted file mode 100644 index 471b84a..0000000 --- a/apps/dashboard/src/components/ui/checkbox.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { CheckIcon } from "lucide-react"; -import { Checkbox as CheckboxPrimitive } from "radix-ui"; -import * as React from "react"; -import { cn } from "@/lib/utils"; - -function Checkbox({ className, ...props }: React.ComponentProps) { - return ( - - - - - - ); -} - -export { Checkbox }; diff --git a/apps/dashboard/src/components/ui/dropdown-menu.tsx b/apps/dashboard/src/components/ui/dropdown-menu.tsx index 8c0fd5f..ba7fb2b 100644 --- a/apps/dashboard/src/components/ui/dropdown-menu.tsx +++ b/apps/dashboard/src/components/ui/dropdown-menu.tsx @@ -1,4 +1,4 @@ -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -107,14 +107,17 @@ function DropdownMenuRadioItem({ - + {children} diff --git a/apps/dashboard/src/components/ui/scroll-area.tsx b/apps/dashboard/src/components/ui/scroll-area.tsx deleted file mode 100644 index 8bbf5b8..0000000 --- a/apps/dashboard/src/components/ui/scroll-area.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; -import * as React from "react"; -import { cn } from "@/lib/utils"; - -function ScrollArea({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - - ); -} - -function ScrollBar({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps) { - return ( - - - - ); -} - -export { ScrollArea, ScrollBar }; diff --git a/apps/dashboard/src/components/ui/toggle-group.tsx b/apps/dashboard/src/components/ui/toggle-group.tsx deleted file mode 100644 index 46f8582..0000000 --- a/apps/dashboard/src/components/ui/toggle-group.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { VariantProps } from "class-variance-authority"; -import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"; -import * as React from "react"; -import { toggleVariants } from "@/components/ui/toggle"; -import { cn } from "@/lib/utils"; - -const ToggleGroupContext = React.createContext< - VariantProps & { spacing?: number } ->({ - size: "default", - variant: "default", - spacing: 0, -}); - -function ToggleGroup({ - className, - variant, - size, - spacing = 0, - children, - ...props -}: React.ComponentProps & - VariantProps & { spacing?: number }) { - return ( - - - {children} - - - ); -} - -function ToggleGroupItem({ - className, - children, - variant, - size, - ...props -}: React.ComponentProps & VariantProps) { - const context = React.useContext(ToggleGroupContext); - - return ( - - {children} - - ); -} - -export { ToggleGroup, ToggleGroupItem }; diff --git a/apps/dashboard/src/components/ui/toggle.tsx b/apps/dashboard/src/components/ui/toggle.tsx deleted file mode 100644 index cd3b10a..0000000 --- a/apps/dashboard/src/components/ui/toggle.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { cva, type VariantProps } from "class-variance-authority"; -import { Toggle as TogglePrimitive } from "radix-ui"; -import * as React from "react"; -import { cn } from "@/lib/utils"; - -const toggleVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-transparent", - outline: - "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", - }, - size: { - default: "h-9 min-w-9 px-2", - sm: "h-8 min-w-8 px-1.5", - lg: "h-10 min-w-10 px-2.5", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -function Toggle({ - className, - variant, - size, - ...props -}: React.ComponentProps & VariantProps) { - return ( - - ); -} - -export { Toggle, toggleVariants }; diff --git a/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx b/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx index 1e2017b..4cc33aa 100644 --- a/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx +++ b/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx @@ -2,10 +2,12 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { SWRConfig } from "swr"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "@/api/session"; import i18n from "@/lib/i18n"; import { AccountSettings } from "./AccountSettings"; +import { useThemeStore } from "@/lib/stores/theme"; +import { ThemeProvider } from "@/providers/ThemeProvider"; vi.mock("@/api/session", async (original) => ({ ...(await original()), getSession: vi.fn(), @@ -15,15 +17,24 @@ vi.mock("@/api/session", async (original) => ({ })); beforeEach(async () => { vi.resetAllMocks(); + useThemeStore.setState({ mode: "light" }); await i18n.changeLanguage("en-US"); }); +afterEach(() => { + localStorage.removeItem("app_theme_mode"); + document.documentElement.classList.remove("dark", "light"); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.style.colorScheme = ""; +}); function mount() { return render( new Map(), dedupingInterval: 0, shouldRetryOnError: false }} > - + + + , ); @@ -66,3 +77,28 @@ describe("account state and recovery", () => { expect(api.startLogin).not.toHaveBeenCalled(); }); }); + +describe("theme preference", () => { + it.each(["en-US", "zh-CN"])( + "saves the theme and follows shared theme changes in %s", + async (locale) => { + await i18n.changeLanguage(locale); + vi.mocked(api.getSession).mockResolvedValue({ session: { loggedIn: false, expired: false } }); + const user = userEvent.setup(); + mount(); + await user.click(screen.getByRole("combobox", { name: i18n.t("session.theme") })); + await user.click(await screen.findByRole("option", { name: i18n.t("session.themeDark") })); + expect(useThemeStore.getState().mode).toBe("dark"); + expect(localStorage.getItem("app_theme_mode")).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + await act(async () => { + useThemeStore.getState().toggle(); + }); + expect(screen.getByRole("combobox", { name: i18n.t("session.theme") }).textContent).toBe( + i18n.t("session.themeLight"), + ); + expect(localStorage.getItem("app_theme_mode")).toBe("light"); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }, + ); +}); diff --git a/apps/dashboard/src/features/infisical-session/AccountSettings.tsx b/apps/dashboard/src/features/infisical-session/AccountSettings.tsx index da6ecd5..a582171 100644 --- a/apps/dashboard/src/features/infisical-session/AccountSettings.tsx +++ b/apps/dashboard/src/features/infisical-session/AccountSettings.tsx @@ -4,6 +4,8 @@ import { Languages, LogIn, LogOut, + MoonStar, + SunMedium, RefreshCw, Settings2, ShieldCheck, @@ -22,9 +24,18 @@ import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useThemeStore, type ThemeMode } from "@/lib/stores/theme"; export function AccountSettings() { const { t } = useTranslation(); + const { mode: theme, setMode: setTheme } = useThemeStore(); const state = useSWR(sessionKey, getSession, { refreshInterval: 2000 }); const { mutate } = useSWRConfig(); const [site, setSite] = useState("https://app.infisical.com"); @@ -228,6 +239,34 @@ export function AccountSettings() { + + + + + +
); } diff --git a/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts b/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts index 19b6fd3..0445211 100644 --- a/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts +++ b/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts @@ -1,5 +1,5 @@ import { createStore } from "@/lib/utils"; -import type { ProjectManifestPatch, JsonValue, WorkspaceManifestPatch } from "@/types/api"; +import type { ProjectManifestPatch, WorkspaceManifestPatch } from "@/types/api"; export type ManifestDraftSection = "general" | "environment"; @@ -212,9 +212,3 @@ export const useManifestDraftStore = createStore( }), "manifestDraftStore", ); - -export function displayDraftValue(value: DraftValue): string { - if (value === undefined || value === null || value === "") return "—"; - if (typeof value === "boolean") return value ? "true" : "false"; - return String(value satisfies JsonValue); -} diff --git a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx new file mode 100644 index 0000000..ce04672 --- /dev/null +++ b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx @@ -0,0 +1,201 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { SWRConfig } from "swr"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { CreateProjectDialog } from "@/features/project-creation/CreateProjectDialog"; +import i18n from "@/lib/i18n"; + +const emptyTemplates = [ + { id: "empty-app", category: "frontend", directory: "apps" }, + { id: "empty-service", category: "backend", directory: "services" }, + { id: "empty-library", category: "library", directory: "packages" }, +].map((template) => ({ + ...template, + name: template.id, + description: template.id, + toolchain: "none", +})); + +const templates = [ + ...emptyTemplates, + { + id: "react-spa", + name: "React", + description: "React", + category: "frontend", + directory: "apps", + toolchain: "node", + }, + { + id: "go-api", + name: "Go", + description: "Go", + category: "backend", + directory: "services", + toolchain: "go", + }, +]; +const server = setupServer(); +function show(projectNames: string[] = []) { + const onClose = vi.fn(); + const onCreated = vi.fn(); + render( + new Map(), shouldRetryOnError: false }}> + + , + ); + return { onClose, onCreated }; +} + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeEach(async () => { + await i18n.changeLanguage("en-US"); + server.use( + http.get("http://localhost/api/project-templates", () => + HttpResponse.json({ templates: [...templates].reverse() }), + ), + ); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("project creation", () => { + it.each( + ["en-US", "zh-CN"].flatMap((locale) => + [...emptyTemplates, { id: "go-api", directory: "services" }].map((template) => ({ + locale, + ...template, + })), + ), + )("creates $id in $locale", async ({ locale, id, directory }) => { + await i18n.changeLanguage(locale); + const user = userEvent.setup(); + let payload: unknown; + server.use( + http.post( + "http://localhost/api/workspaces/:entryId/projects", + async ({ params, request }) => { + expect(params.entryId).toBe("selected-workspace"); + payload = await request.json(); + return HttpResponse.json( + { name: "api", relativeDir: `${directory}/api`, templateId: id }, + { status: 201 }, + ); + }, + ), + ); + const { onClose, onCreated } = show(); + await user.type(screen.getByLabelText(i18n.t("projectCreate.name")), "api"); + const select = await screen.findByRole("combobox", { name: i18n.t("projectCreate.template") }); + await waitFor(() => expect((select as HTMLButtonElement).disabled).toBe(false)); + await user.click(select); + await user.click( + await screen.findByRole("option", { name: i18n.t(`projectCreate.templates.${id}.name`) }), + ); + expect(screen.getByText(`${directory}/api`)).toBeDefined(); + await user.click(screen.getByRole("button", { name: i18n.t("projectCreate.submit") })); + await waitFor(() => expect(onCreated).toHaveBeenCalledWith("api")); + expect(payload).toEqual({ name: "api", templateId: id }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("validates names and duplicates without sending requests", async () => { + const user = userEvent.setup(); + const post = vi.fn(); + server.use(http.post("http://localhost/api/workspaces/:entryId/projects", post)); + show(["web"]); + const name = screen.getByLabelText("Project name"); + const submit = screen.getByRole("button", { name: "Create project" }); + await waitFor(() => expect((submit as HTMLButtonElement).disabled).toBe(false)); + for (const value of ["../outside", "web"]) { + await user.clear(name); + await user.type(name, value); + await user.click(submit); + expect(name.getAttribute("aria-invalid")).toBe("true"); + expect(document.activeElement).toBe(name); + expect(screen.getByRole("alert")).toBeDefined(); + } + expect(post).not.toHaveBeenCalled(); + }); + + it("retains input on server errors and allows retry", async () => { + const user = userEvent.setup(); + let calls = 0; + server.use( + http.post("http://localhost/api/workspaces/:entryId/projects", () => { + calls++; + if (calls === 1) + return HttpResponse.json( + { error: { code: "TARGET_EXISTS", message: "Directory already exists" } }, + { status: 409 }, + ); + return HttpResponse.json( + { name: "web", relativeDir: "apps/web", templateId: "react-spa" }, + { status: 201 }, + ); + }), + ); + const { onCreated } = show(); + await user.type(screen.getByLabelText("Project name"), "web"); + await user.click(screen.getByRole("button", { name: "Create project" })); + expect(await screen.findByText("Directory already exists")).toBeDefined(); + expect((screen.getByLabelText("Project name") as HTMLInputElement).value).toBe("web"); + await user.click(screen.getByRole("button", { name: "Create project" })); + await waitFor(() => expect(onCreated).toHaveBeenCalledWith("web")); + }); + + it("blocks duplicate submissions and closing while creation is pending", async () => { + const user = userEvent.setup(); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const post = vi.fn(async () => { + await blocked; + return HttpResponse.json( + { name: "web", relativeDir: "apps/web", templateId: "react-spa" }, + { status: 201 }, + ); + }); + server.use(http.post("http://localhost/api/workspaces/:entryId/projects", post)); + const { onClose } = show(); + await user.type(screen.getByLabelText("Project name"), "web"); + await user.dblClick(screen.getByRole("button", { name: "Create project" })); + expect(await screen.findByRole("status")).toBeDefined(); + expect((screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement).disabled).toBe( + true, + ); + await user.keyboard("{Escape}"); + expect(onClose).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledOnce(); + release(); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + }); + + it("recovers from template loading failure and updates translated template labels", async () => { + const user = userEvent.setup(); + server.use( + http.get( + "http://localhost/api/project-templates", + () => new HttpResponse(null, { status: 500 }), + ), + ); + show(); + expect(await screen.findByText("Could not load templates")).toBeDefined(); + server.use( + http.get("http://localhost/api/project-templates", () => HttpResponse.json({ templates })), + ); + await user.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByRole("combobox", { name: i18n.t("projectCreate.template") }); + await i18n.changeLanguage("zh-CN"); + const dialog = await screen.findByRole("dialog", { name: "新建项目" }); + expect(await within(dialog).findByText("React 单页应用")).toBeDefined(); + }); +}); diff --git a/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx b/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx new file mode 100644 index 0000000..999d077 --- /dev/null +++ b/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx @@ -0,0 +1,238 @@ +import { useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR, { useSWRConfig } from "swr"; +import { createProject, getProjectTemplates, projectTemplatesKey } from "@/api/project-creation"; +import { workspaceBasePath, workspacesKey } from "@/api/workspaces"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { useToast } from "@/hooks/useToast"; +import type { HttpError } from "@/types/api"; + +interface Props { + entryId: string; + projectNames: string[]; + onClose(): void; + onCreated(name: string): void; +} + +export function CreateProjectDialog({ entryId, projectNames, onClose, onCreated }: Props) { + const { t } = useTranslation(); + const { mutate } = useSWRConfig(); + const toast = useToast(); + const id = useId(); + const nameInput = useRef(null); + const submitting = useRef(false); + const [name, setName] = useState(""); + const [templateId, setTemplateId] = useState("react-spa"); + const [pending, setPending] = useState(false); + const [nameError, setNameError] = useState(""); + const [failure, setFailure] = useState(); + const catalog = useSWR(projectTemplatesKey, getProjectTemplates); + const templates = catalog.data?.templates ?? []; + const selected = templates.find((entry) => entry.id === templateId) ?? templates[0]; + const cleanName = name.trim(); + const validName = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(cleanName); + const categoryKeys = ["frontend", "backend", "library"] as const; + + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (submitting.current || !selected) return; + const validation = !validName + ? "projectCreate.nameInvalid" + : projectNames.includes(cleanName) + ? "projectCreate.nameExists" + : ""; + setNameError(validation); + if (validation) { + nameInput.current?.focus(); + return; + } + submitting.current = true; + setPending(true); + setFailure(undefined); + let result; + try { + result = await createProject(entryId, { name: cleanName, templateId: selected.id }); + } catch (cause) { + setFailure(cause as HttpError); + setPending(false); + submitting.current = false; + return; + } + // A refresh failure must never turn a successful creation into a retry. + const refreshed = await Promise.allSettled([ + mutate((key) => typeof key === "string" && key.startsWith(`${workspaceBasePath(entryId)}/`)), + mutate(workspacesKey), + ]); + onCreated(result.name); + toast.success(t("projectCreate.success", { name: result.name })); + for (const warning of result.warnings ?? []) toast.warning(warning); + if (refreshed.some((item) => item.status === "rejected")) + toast.warning(t("projectCreate.refreshFailed")); + onClose(); + } + + return ( + { + if (!open && !submitting.current) onClose(); + }} + > + event.preventDefault()} + > + + {t("projectCreate.title")} + {t("projectCreate.description")} + +
+ + {t("projectCreate.name")} + { + setName(event.target.value); + setNameError(""); + }} + /> + + {t(nameError || "projectCreate.nameHint")} + + + + {t("projectCreate.template")} + {catalog.error ? ( + + {t("projectCreate.templatesFailed")} + + + + + ) : !selected ? ( +

+ {t( + catalog.isLoading + ? "projectCreate.loadingTemplates" + : "projectCreate.noTemplates", + )} +

+ ) : ( + <> + + {selected && ( + + {t(`projectCreate.templates.${selected.id}.description`, { + defaultValue: selected.description, + })} + + )} + + )} +
+ {selected && ( +
+

{t("projectCreate.location")}

+

+ {selected.directory}/{validName ? cleanName : "…"} +

+

+ {t("projectCreate.locationHint")} +

+
+ )} + {failure && ( + + {t("projectCreate.failed")} + + {failure.status === 0 ? t("projectCreate.connectionLost") : failure.message} + + + )} + {pending && ( +

+

+ )} + + + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx index 2468abe..62f1ac3 100644 --- a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx +++ b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx @@ -2,12 +2,24 @@ import { manifestDraftKey, useManifestDraftStore, } from "@/features/manifest-draft/manifest-draft-store"; -import { Server, Code2, FileKey2, Library, Search, LayoutGrid, LockKeyhole } from "lucide-react"; +import { + Plus, + Terminal, + Server, + Code2, + FileKey2, + Library, + Search, + LayoutGrid, + LockKeyhole, +} from "lucide-react"; import type React from "react"; import { useEffect, useId, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; +import { getServices, servicesKey } from "@/api/services"; import { getProjectSettings, projectSettingsKey } from "@/api/workspace"; +import { CreateProjectDialog } from "@/features/project-creation/CreateProjectDialog"; import { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { @@ -35,12 +47,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EnvironmentSelector } from "@/features/environment-context/EnvironmentSelector"; import { useEnvironmentDirtyStore } from "@/features/environment-context/environment-dirty-store"; import { EnvironmentForm } from "@/features/project-settings/forms/EnvironmentForm"; +import { ServicePanel } from "@/features/services/ServicePanel"; import { GeneralForm } from "@/features/project-settings/forms/GeneralForm"; -import type { ProjectInspectorTab } from "@/features/project-settings/ProjectMatrix"; import { WorkspaceSettingsDialog } from "@/features/workspace-settings/WorkspaceSettingsDialog"; import { cn } from "@/lib/utils"; import type { OverviewProject, ProjectSettingsResponse } from "@/types/api"; +type ProjectInspectorTab = "overview" | "environment" | "runtime"; + interface ProjectInspectorProps { projects: OverviewProject[]; currentBackend?: string; @@ -54,6 +68,7 @@ const TAB_ITEMS: ReadonlyArray<{ icon: React.ComponentType<{ className?: string }>; }> = [ { id: "overview", icon: LayoutGrid }, + { id: "runtime", icon: Terminal }, { id: "environment", icon: FileKey2 }, ]; @@ -66,7 +81,12 @@ export const ProjectInspector: React.FC = ({ }) => { const { t } = useTranslation(); const dirtyOwner = useId(); + const services = useSWR(servicesKey(workspaceEntryId), () => getServices(workspaceEntryId), { + refreshInterval: 2000, + }); const [query, setQuery] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const canCreate = !!workspaceEntryId && !readOnly; const [dirty, setDirty] = useState(false); const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); const [selectedName, setSelectedName] = useState(projects[0]?.name ?? ""); @@ -139,6 +159,9 @@ export const ProjectInspector: React.FC = ({ {filteredProjects.map((project) => { const Icon = PROJECT_KIND_ICON[project.kind]; const selected = project.name === selectedProject?.name; + const status = services.data?.services.find( + (service) => service.project === project.name, + )?.status; return (
) : null} + {canCreate && ( + + )}
@@ -207,7 +259,17 @@ export const ProjectInspector: React.FC = ({ {selectedProject?.relativeDir}

-
+
+ {canCreate && ( + + )} = ({
+ {createOpen && canCreate && workspaceEntryId && ( + project.name)} + onClose={() => setCreateOpen(false)} + onCreated={(name) => { + setQuery(""); + setInspectorDirty(false); + setSelectedName(name); + }} + /> + )} + { @@ -427,6 +502,16 @@ const ProjectSettingsPanel: React.FC = ({ onDirtyChange, }) => { const project = data.project; + if (activeTab === "runtime") { + return ( + + ); + } if (activeTab === "overview") { return ( > = { - app: Code2, - service: Boxes, - package: Library, -}; - -function issueFor( - project: OverviewProject, - domain: OverviewIssueDomain, -): OverviewIssue | undefined { - return project.issues?.find((issue) => issue.domain === domain); -} - -function projectSearchText(project: OverviewProject): string { - return [ - project.name, - project.relativeDir, - project.kind, - project.templateId, - project.toolchain, - ...Object.values(project.domains ?? {}), - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); -} - -interface DomainCellProps { - project: OverviewProject; - domain: MatrixDomain; - backend?: string; - readOnly?: boolean; - onClick(): void; -} - -const DOMAIN_ICON: Record> = { - env: KeyRound, -}; - -const DomainCell: React.FC = ({ project, domain, backend, readOnly, onClick }) => { - const { t } = useTranslation(); - const issue = issueFor(project, domain); - const Icon = DOMAIN_ICON[domain]; - - return ( - - ); -}; - -export const ProjectMatrix: React.FC = ({ - projects, - workspaceEnvironment, - readOnly, - onInspect, -}) => { - const { t } = useTranslation(); - const [query, setQuery] = useState(""); - const [kind, setKind] = useState<"all" | OverviewProjectKind>("all"); - const [status, setStatus] = useState<"all" | "attention" | "healthy">("all"); - const [filtersOpen, setFiltersOpen] = useState(false); - const filtered = useMemo(() => { - const normalized = query.trim().toLowerCase(); - return projects - .filter((project) => { - const hasIssues = (project.issues?.length ?? 0) > 0; - return ( - (kind === "all" || project.kind === kind) && - (status === "all" || (status === "attention" ? hasIssues : !hasIssues)) && - (!normalized || projectSearchText(project).includes(normalized)) - ); - }) - .sort( - (left, right) => - Number((right.issues?.length ?? 0) > 0) - Number((left.issues?.length ?? 0) > 0), - ); - }, [kind, projects, query, status]); - - return ( - -
-
-
-

- {t("projects.title")} -

- - {projects.length} - -
-

{t("projects.description")}

-
- -
- - {filtersOpen ? ( -
-
- -
- - - - - setQuery(event.target.value)} - placeholder={t("projects.search")} - aria-label={t("projects.search")} - className="text-xs" - /> - -
- -
-
- ) : null} - - {filtered.length === 0 ? ( - - - - - - {t("projects.empty.title")} - {t("projects.empty.description")} - - - ) : ( - - - - {t("projects.matrix.project")} - {t("projects.matrix.environment")} - - {t("projects.matrix.status")} - - - - - {filtered.map((project) => { - const Icon = KIND_ICON[project.kind]; - const issueCount = project.issues?.length ?? 0; - return ( - - - - - - onInspect(project, "environment")} - /> - - - {issueCount > 0 ? ( - - ) : ( - - - {t("projects.matrix.healthy")} - - )} - - - ); - })} - -
- )} -
- ); -}; diff --git a/apps/dashboard/src/features/project-settings/forms/FormLayout.tsx b/apps/dashboard/src/features/project-settings/forms/FormLayout.tsx index 206b92d..c7986c8 100644 --- a/apps/dashboard/src/features/project-settings/forms/FormLayout.tsx +++ b/apps/dashboard/src/features/project-settings/forms/FormLayout.tsx @@ -18,16 +18,6 @@ export const ManifestDraftLayout: React.FC = ({ childre
{children}
); -export const FormLayout: React.FC< - React.PropsWithChildren<{ - title: string; - }> -> = ({ title, children }) => ( -
- {children} -
-); - export const ProjectField: React.FC< React.PropsWithChildren<{ label: string; htmlFor: string }> > = ({ label, htmlFor, children }) => ( diff --git a/apps/dashboard/src/features/project-settings/forms/GeneralForm.tsx b/apps/dashboard/src/features/project-settings/forms/GeneralForm.tsx index b4a1c7b..836e9cc 100644 --- a/apps/dashboard/src/features/project-settings/forms/GeneralForm.tsx +++ b/apps/dashboard/src/features/project-settings/forms/GeneralForm.tsx @@ -29,6 +29,7 @@ export const GeneralForm: React.FC = ({ const initial: ProjectGeneralPatch = { buildVersion: project.buildVersion ?? "", devCommand: project.devCommand ?? "", + devURL: project.devURL ?? "", }; const value = staged ?? initial; const build = project.build; @@ -44,6 +45,7 @@ export const GeneralForm: React.FC = ({ labels: { buildVersion: "projectInspector.general.buildVersion", devCommand: "projectInspector.general.devCommand", + devURL: "projectInspector.general.devURL", }, }); } @@ -102,6 +104,19 @@ export const GeneralForm: React.FC = ({ readOnly={readOnly} /> + + update({ ...value, devURL: event.target.value })} + aria-describedby="project-dev-url-hint" + /> +

+ {t("projectInspector.general.devURLHint")} +

+
= ({
+ {project.tasks && ( +
+ + {project.tasks.status === "unavailable" ? ( +

+ {t("projectInspector.general.tasksUnavailable")} +

+ ) : project.tasks.entries.length === 0 ? ( +

+ {t("projectInspector.general.tasksEmpty")} +

+ ) : ( + project.tasks.entries.map((task) => ( +
+

{task.name}

+

+ {t("projectInspector.general.taskSource", { source: task.source })} +

+

+ {t( + task.cacheEnabled + ? "projectInspector.general.taskCacheOn" + : "projectInspector.general.taskCacheOff", + )} +

+ {!!task.depends?.length && ( +

+ {t("projectInspector.general.taskDepends", { tasks: task.depends.join(", ") })} +

+ )} + {!!task.outputs?.length && ( +

+ {t("projectInspector.general.taskOutputs", { + outputs: task.outputs.join(", "), + })} +

+ )} +
+ )) + )} +
+ )} ); }; diff --git a/apps/dashboard/src/features/project-settings/forms/helpers.ts b/apps/dashboard/src/features/project-settings/forms/helpers.ts deleted file mode 100644 index 9b8a494..0000000 --- a/apps/dashboard/src/features/project-settings/forms/helpers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { overviewKeyFor } from "@/api/workspace"; -import type { useToast } from "@/hooks/useToast"; -import type { JsonValue } from "@/types/api"; - -export function refreshOverview( - mutate: (key: string) => unknown, - workspaceEntryId?: string, - environment?: string, -): void { - void mutate(overviewKeyFor(workspaceEntryId, environment)); -} - -export function showSaveError( - toast: ReturnType, - title: string, - error: unknown, -): void { - const failure = error as { code?: string; message?: string }; - toast.error(title, { description: failure.message ?? String(error) }); -} - -export function configPathValue( - config: Record, - path: string, -): JsonValue | undefined { - let value: JsonValue | undefined = config; - for (const segment of path.split("/").filter(Boolean)) { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - value = (value as Record)[segment]; - } - return value; -} - -export function setConfigPathValue( - config: Record, - path: string, - nextValue: string, -): Record { - const result = structuredClone(config); - const segments = path.split("/").filter(Boolean); - let current: Record = result; - for (const segment of segments.slice(0, -1)) { - const existing = current[segment]; - if (!existing || typeof existing !== "object" || Array.isArray(existing)) { - current[segment] = {}; - } - current = current[segment] as Record; - } - current[segments.at(-1) ?? path] = nextValue; - return result; -} diff --git a/apps/dashboard/src/features/services/ServicePanel.test.tsx b/apps/dashboard/src/features/services/ServicePanel.test.tsx new file mode 100644 index 0000000..46c8ace --- /dev/null +++ b/apps/dashboard/src/features/services/ServicePanel.test.tsx @@ -0,0 +1,156 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { controlService, watchService } from "@/api/services"; +import { useManifestDraftStore } from "@/features/manifest-draft/manifest-draft-store"; +import { mergeService, ServicePanel } from "@/features/services/ServicePanel"; +import { consoleText, renderConsole } from "@/features/services/console"; +import i18n from "@/lib/i18n"; +import type { DevService } from "@/types/api"; +vi.mock("@/api/services", () => ({ controlService: vi.fn(), watchService: vi.fn() })); +const idle: DevService = { + id: "", + project: "web", + environment: "dev", + status: "stopped", + startedAt: "", + logs: [], + endpoints: [], + firstSeq: 1, + nextSeq: 1, +}; +let receive: (state: DevService) => void; +let connection: (connected: boolean) => void; +const close = vi.fn(); +beforeEach(async () => { + vi.resetAllMocks(); + useManifestDraftStore.setState({ drafts: {} }); + await i18n.changeLanguage("en-US"); + vi.mocked(watchService).mockImplementation((_project, _entry, onState, onConnection) => { + receive = onState; + connection = onConnection; + onState(idle); + onConnection(true); + return close; + }); + vi.mocked(controlService).mockResolvedValue({ ...idle, id: "1", status: "preparing" }); +}); +function mount(environment = "dev") { + return render( + , + ); +} +describe("service console", () => { + it("launches the selected environment and keeps stop available with a draft", async () => { + const user = userEvent.setup(); + mount("prod"); + await user.click(screen.getByRole("button", { name: "Start service" })); + expect(controlService).toHaveBeenCalledWith("web", "entry", "start", "prod"); + act(() => + receive({ + ...idle, + id: "1", + status: "running", + environment: "dev", + endpoints: [{ url: "http://localhost:3000/", reachable: true }], + logs: [{ seq: 1, text: "ready\n" }], + nextSeq: 2, + }), + ); + expect(screen.getByText(/This service uses dev/)).toBeTruthy(); + expect(screen.getByRole("link", { name: "Open" }).getAttribute("href")).toBe( + "http://localhost:3000/", + ); + act(() => + useManifestDraftStore.getState().stageSection({ + entryId: "entry", + project: "web", + revision: "x", + section: "general", + initial: { devCommand: "old" }, + next: { devCommand: "new" }, + labels: {}, + }), + ); + expect((screen.getByRole("button", { name: "Restart" }) as HTMLButtonElement).disabled).toBe( + true, + ); + await user.click(screen.getByRole("button", { name: "Stop" })); + expect(controlService).toHaveBeenLastCalledWith("web", "entry", "stop", "prod"); + }); + it("reconnects without duplicating logs, filters text and closes only the subscription", async () => { + const user = userEvent.setup(); + const view = mount(); + const next = { + ...idle, + id: "1", + status: "running" as const, + logs: [ + { seq: 1, text: "hello\n" }, + { seq: 2, text: "world\n" }, + ], + nextSeq: 3, + }; + act(() => { + receive(next); + connection(false); + receive(next); + connection(true); + }); + expect(screen.getByLabelText("Console", { selector: "pre" }).textContent).toBe( + "hello\nworld\n", + ); + await user.type(screen.getByRole("textbox", { name: "Search logs" }), "world"); + expect(screen.getByLabelText("Console", { selector: "pre" }).textContent).toBe("world"); + view.unmount(); + expect(close).toHaveBeenCalled(); + expect(controlService).not.toHaveBeenCalled(); + }); + it("shows failures without claiming the service started, in both languages", async () => { + await i18n.changeLanguage("zh-CN"); + vi.mocked(controlService).mockRejectedValue({ message: "端口占用" }); + const user = userEvent.setup(); + mount(); + await user.click(screen.getByRole("button", { name: "启动服务" })); + await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("端口占用")); + expect(screen.getByText("已停止")).toBeTruthy(); + }); + it("resets logs on a new run and removes expired chunks", () => { + const previous = { + ...idle, + id: "1", + logs: [ + { seq: 1, text: "old" }, + { seq: 2, text: "keep" }, + ], + nextSeq: 3, + }; + expect( + mergeService(previous, { + ...previous, + firstSeq: 2, + logs: [{ seq: 3, text: "new" }], + nextSeq: 4, + }).logs.map((x) => x.text), + ).toEqual(["keep", "new"]); + expect(mergeService(previous, { ...idle, id: "2" }).logs).toEqual([]); + }); + it("renders ANSI text without interpreting HTML or terminal hyperlinks", () => { + const raw = + "\x1b[31m\x1b[0m\x1b]8;;https://evil.example\x07link\x1b]8;;\x07"; + const { container } = render(
{renderConsole(raw)}
); + expect(container.querySelector("script,a")).toBeNull(); + expect(container.textContent).toBe("link"); + expect(consoleText(raw)).toBe("link"); + }); +}); diff --git a/apps/dashboard/src/features/services/ServicePanel.tsx b/apps/dashboard/src/features/services/ServicePanel.tsx new file mode 100644 index 0000000..076c32b --- /dev/null +++ b/apps/dashboard/src/features/services/ServicePanel.tsx @@ -0,0 +1,281 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Copy, + ExternalLink, + LoaderCircle, + Pause, + Play, + RotateCw, + Search, + Square, + Terminal, +} from "lucide-react"; +import { toast } from "sonner"; +import { controlService, watchService } from "@/api/services"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { + manifestDraftKey, + useManifestDraftStore, +} from "@/features/manifest-draft/manifest-draft-store"; +import { consoleText, renderConsole } from "@/features/services/console"; +import type { DevService, ProjectSettings } from "@/types/api"; + +export function mergeService(previous: DevService | undefined, next: DevService): DevService { + const retained = + previous?.id === next.id ? previous.logs.filter((line) => line.seq >= next.firstSeq) : []; + const last = retained.at(-1)?.seq ?? 0; + return { + ...next, + logs: [...retained, ...next.logs.filter((line) => line.seq > last)].slice(-2048), + }; +} +export function ServicePanel({ + project, + environment, + entryId, + readOnly, +}: { + project: ProjectSettings; + environment: string; + entryId?: string; + readOnly?: boolean; +}) { + const { t } = useTranslation(); + const [state, setState] = useState(); + const [connected, setConnected] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [query, setQuery] = useState(""); + const [follow, setFollow] = useState(true); + const viewport = useRef(null); + const draft = useManifestDraftStore((store) => store.drafts[manifestDraftKey(entryId)]); + useEffect( + () => + watchService( + project.name, + entryId, + (next) => setState((previous) => mergeService(previous, next)), + setConnected, + ), + [entryId, project.name], + ); + const active = state && ["preparing", "running", "stopping"].includes(state.status); + const busy = pending || state?.status === "stopping"; + const hasDev = + !!project.devCommand || + project.tasks?.entries.some((task) => task.name === "dev" || task.name.endsWith(":dev")); + const raw = state?.logs.map((line) => line.text).join("") ?? ""; + const text = useMemo(() => consoleText(raw), [raw]); + const visible = useMemo( + () => + query + ? text + .split("\n") + .filter((line) => line.toLocaleLowerCase().includes(query.toLocaleLowerCase())) + .join("\n") + : raw, + [query, text, raw], + ); + const consoleContent = useMemo(() => renderConsole(visible), [visible]); + useEffect(() => { + if (follow && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight; + }, [visible, follow]); + async function control(action: "start" | "stop" | "restart") { + setPending(true); + setError(""); + try { + await controlService(project.name, entryId, action, environment); + } catch (failure) { + setError((failure as { message?: string }).message ?? t("services.actionFailed")); + } finally { + setPending(false); + } + } + async function copy() { + try { + await navigator.clipboard.writeText(query ? visible : text); + toast.success(t("services.copied")); + } catch { + toast.error(t("services.copyFailed")); + } + } + const status = state?.status ?? "stopped"; + return ( +
+
+
+
+
+

{t("services.title")}

+ + {status === "preparing" || status === "stopping" ? ( + + ) : null} + {t(`services.status.${status}`)} + +
+

{t("services.description")}

+

+ one dev -p {project.name} --env {active ? state.environment : environment} +

+
+
+ {active ? ( + <> + + + + ) : ( + + )} +
+
+ {!!draft &&

{t("services.saveFirst")}

} + {!hasDev &&

{t("services.noDev")}

} + {active && state.environment !== environment && ( +

+ {t("services.environmentMismatch", { + environment: state.environment, + next: environment, + })} +

+ )} + {state?.exitCode !== undefined && !active && ( +

+ {t("services.exitCode", { code: state.exitCode })} +

+ )} + {error && ( + + {error} + + )} +
+ {state?.endpoints.length ? ( + state.endpoints.map((endpoint) => ( +
+
+

{endpoint.url}

+

+ {t( + endpoint.reachable && active ? "services.reachable" : "services.unreachable", + )} +

+
+ {endpoint.reachable && active ? ( + + ) : ( + + )} +
+ )) + ) : ( +

+ {t(active ? "services.waitingURL" : "services.urlHint")} +

+ )} +
+
+
+
+
+ +

{t("services.console")}

+ + {t(connected ? "services.live" : "services.reconnecting")} + +
+
+ + + + + setQuery(event.target.value)} + placeholder={t("services.search")} + aria-label={t("services.search")} + /> + + + +
+
+
+					{raw
+						? visible
+							? consoleContent
+							: t("services.noMatches")
+						: t(active ? "services.waitingLogs" : "services.emptyLogs")}
+				
+

+ {t(state && state.firstSeq > 1 ? "services.truncated" : "services.consoleHint")} +

+
+
+ ); +} diff --git a/apps/dashboard/src/features/services/console.tsx b/apps/dashboard/src/features/services/console.tsx new file mode 100644 index 0000000..17c9495 --- /dev/null +++ b/apps/dashboard/src/features/services/console.tsx @@ -0,0 +1,60 @@ +/* eslint-disable no-control-regex -- Terminal output contains ANSI control bytes. */ +import type { ReactNode } from "react"; + +// Only text and SGR colors are rendered. OSC links, HTML and terminal commands +// are never interpreted. Joining retained chunks also handles split sequences. +const controls = + /\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[a-ln-zA-Z]|[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f]/g; +export function consoleText(text: string) { + return text + .replace(controls, "") + .replace(/\x1b\[[0-9;]*m/g, "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); +} +const colors: Record = { + 31: "text-error-foreground", + 32: "text-success-foreground", + 33: "text-warning-foreground", + 34: "text-primary-text", + 35: "text-chart-5", + 36: "text-chart-2", + 90: "text-muted-foreground", + 91: "text-error-foreground", + 92: "text-success-foreground", + 93: "text-warning-foreground", + 94: "text-primary-text", + 95: "text-chart-5", + 96: "text-chart-2", +}; +export function renderConsole(text: string): ReactNode[] { + const clean = text.replace(controls, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const parts = clean.split(/(\x1b\[[0-9;]*m)/g); + let color = ""; + let bold = false; + return parts.map((part, index) => { + if (part.startsWith("\x1b[")) { + const codes = part.slice(2, -1).split(";").map(Number); + for (let i = 0; i < codes.length; i++) { + const code = codes[i]; + if (code === 38 || code === 48) { + i += codes[i + 1] === 2 ? 4 : 2; + continue; + } + if (code === 0) { + color = ""; + bold = false; + } else if (code === 1) bold = true; + else if (code === 22) bold = false; + else if (code === 39) color = ""; + else if (colors[code]) color = colors[code]; + } + return null; + } + return ( + + {part} + + ); + }); +} diff --git a/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx b/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx deleted file mode 100644 index b6d7e74..0000000 --- a/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { AlertCircle, ArrowRight, CheckCircle2, FolderCog } from "lucide-react"; -import type React from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import type { ProjectInspectorTab } from "@/features/project-settings/ProjectMatrix"; -import type { OverviewIssue, OverviewProject } from "@/types/api"; - -interface WorkspaceActionCenterProps { - projects: OverviewProject[]; - workspaceIssues: OverviewIssue[]; - readOnly?: boolean; - issueMessage(issue: OverviewIssue): string; - onInspect(project: OverviewProject, tab: ProjectInspectorTab): void; -} - -function issueKey(issue: OverviewIssue): string { - return [issue.domain, issue.reason ?? "backend", issue.backend ?? "", issue.profile ?? ""].join( - ":", - ); -} - -function profileIssueSettingsPath(issue: OverviewIssue): string { - const section = issue.section ?? (issue.backend ? `${issue.domain}/${issue.backend}` : ""); - const [domain, backend, extra] = section.split("/"); - if (!domain || !backend || extra) return "/settings"; - return `/settings/${encodeURIComponent(domain)}/${encodeURIComponent(backend)}`; -} - -export const WorkspaceActionCenter: React.FC = ({ - projects, - workspaceIssues, - readOnly, - issueMessage, - onInspect, -}) => { - const { t } = useTranslation(); - function jumpToSection(event: React.MouseEvent, href: string) { - event.preventDefault(); - document.querySelector(href)?.scrollIntoView({ behavior: "smooth", block: "start" }); - window.history.replaceState(null, "", href); - } - const projectActions = projects.flatMap((project) => - (project.issues ?? []).map((issue) => ({ project, issue })), - ); - const total = workspaceIssues.length + projectActions.length; - - return ( - -
-
-

- {total > 0 ? t("overview.actionCenter.title") : t("overview.actionCenter.allGood")} -

-

- {total > 0 - ? t("overview.actionCenter.hint") - : t("overview.actionCenter.allGoodDescription")} -

-
- - {String(total).padStart(2, "0")} - -
- - {total === 0 ? ( -
-
-
- -
-

- {t("overview.actionCenter.clearDescription")} -

-
-
- ) : ( - <> -
- {workspaceIssues.map((issue) => ( -
- - - -
-

- {t("overview.actionCenter.workspaceLabel")} -

-

{issueMessage(issue)}

-
- -
- ))} - - {projectActions.map(({ project, issue }) => ( -
- - - -
-

- {project.name} - {project.relativeDir} -

-

{issueMessage(issue)}

-
- -
- ))} -
- jumpToSection(event, "#workspace-projects")} - className="flex h-9 items-center justify-between border-t border-border bg-muted/25 px-3 font-mono text-[9px] uppercase tracking-wide text-primary hover:bg-muted/45" - > - {t("overview.actionCenter.viewProjects")} - - - - )} -
- ); -}; diff --git a/apps/dashboard/src/features/workspace-registry/WorkspaceMenu.tsx b/apps/dashboard/src/features/workspace-registry/WorkspaceMenu.tsx new file mode 100644 index 0000000..1281fba --- /dev/null +++ b/apps/dashboard/src/features/workspace-registry/WorkspaceMenu.tsx @@ -0,0 +1,93 @@ +import { Check } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useMatch } from "react-router-dom"; +import useSWR from "swr"; +import { getWorkspaces, workspacesKey } from "@/api/workspaces"; +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; +import { cn } from "@/lib/utils"; +import type { WorkspaceRegistryStatus } from "@/types/api"; + +const STATUS_DOT_CLASS: Record = { + ready: "bg-success-foreground", + missing: "bg-muted-foreground", + invalid: "bg-error-foreground", + "identity-missing": "bg-warning-foreground", + "identity-conflict": "bg-warning-foreground", +}; + +export function WorkspaceMenu() { + const { t } = useTranslation(); + const activeEntryId = useMatch("/workspace/:entryId")?.params.entryId; + const registry = useSWR(workspacesKey, getWorkspaces); + const workspaces = registry.data?.workspaces ?? []; + return ( + <> + + {t("workspaces.rail.title")} + {registry.data ? workspaces.length : ""} + + + {registry.isLoading && ( +

+ {t("workspaces.loading")} +

+ )} + {registry.error && ( + <> +

+ {t("workspaces.rail.loadFailed")} +

+ { + event.preventDefault(); + void registry.mutate().catch(() => undefined); + }} + > + {t("workspaces.retry")} + + + )} + {!registry.isLoading && !registry.error && workspaces.length === 0 && ( +

+ {t("workspaces.rail.empty")} +

+ )} + {workspaces.map((workspace) => { + const active = workspace.entryId === activeEntryId; + return ( + + + + {workspace.name} + + {workspace.projectCount} + + {active && + + ); + })} +
+ + ); +} diff --git a/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx b/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx deleted file mode 100644 index ee69cc2..0000000 --- a/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { Trash2 } from "lucide-react"; -import type React from "react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useLocation, useMatch, useNavigate } from "react-router-dom"; -import useSWR from "swr"; -import { forgetWorkspace, getWorkspaces, workspacesKey } from "@/api/workspaces"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import { preserveEnvironment } from "@/features/environment-context/environment"; -import { useToast } from "@/hooks/useToast"; -import { cn } from "@/lib/utils"; -import type { WorkspaceRegistryEntry, WorkspaceRegistryStatus } from "@/types/api"; - -const STATUS_DOT_CLASS: Record = { - ready: "bg-success-foreground", - missing: "bg-muted-foreground", - invalid: "bg-error-foreground", - "identity-missing": "bg-warning-foreground", - "identity-conflict": "bg-warning-foreground", -}; - -const WorkspaceRailItem: React.FC<{ - workspace: WorkspaceRegistryEntry; - active: boolean; - forgetting: boolean; - onForget(): void; -}> = ({ workspace, active, forgetting, onForget }) => { - const { t } = useTranslation(); - - return ( -
- {active ? : null} - - - - - {workspace.name} - - - {workspace.projectCount} - - - - - - - - {t("workspaces.forget.short")} - -
- ); -}; - -export const WorkspaceRail: React.FC = () => { - const { t } = useTranslation(); - const toast = useToast(); - const navigate = useNavigate(); - const { search } = useLocation(); - const workspaceMatch = useMatch("/workspace/:entryId"); - const activeEntryId = workspaceMatch?.params.entryId; - const registry = useSWR(workspacesKey, getWorkspaces); - const [forgetting, setForgetting] = useState(null); - const [workspaceToForget, setWorkspaceToForget] = useState(null); - const workspaces = registry.data?.workspaces ?? []; - - async function handleForget(workspace: WorkspaceRegistryEntry) { - setForgetting(workspace.entryId); - try { - await forgetWorkspace(workspace.entryId); - await registry.mutate( - (current) => - current - ? { - ...current, - currentEntryId: - current.currentEntryId === workspace.entryId ? undefined : current.currentEntryId, - workspaces: current.workspaces.filter( - (entry) => entry.entryId !== workspace.entryId, - ), - } - : current, - { revalidate: false }, - ); - if (activeEntryId === workspace.entryId) { - navigate(preserveEnvironment("/", search), { replace: true }); - } - toast.success(t("workspaces.forget.done", { name: workspace.name })); - setWorkspaceToForget(null); - } catch (error) { - const failure = error as { message?: string }; - toast.error(t("workspaces.forget.failed"), { - description: failure.message ?? String(error), - }); - } finally { - setForgetting(null); - } - } - - return ( - <> -
-
-
-

{t("workspaces.rail.title")}

-
- {workspaces.length > 0 ? ( - {workspaces.length} - ) : null} -
- - - {/* Constrain the content width inside Radix ScrollArea's intrinsic table wrapper. */} -
- {registry.isLoading ? : null} - {registry.error ? ( -

- {t("workspaces.rail.loadFailed")} -

- ) : null} - {!registry.isLoading && !registry.error && workspaces.length === 0 ? ( -

- {t("workspaces.rail.empty")} -

- ) : null} - {workspaces.map((workspace) => ( - setWorkspaceToForget(workspace)} - /> - ))} -
-
-
- - { - if (!open && !forgetting) setWorkspaceToForget(null); - }} - > - - - {t("workspaces.forget.short")} - - {workspaceToForget - ? t("workspaces.forget.confirm", { name: workspaceToForget.name }) - : ""} - - - - {t("form.cancel")} - { - event.preventDefault(); - if (workspaceToForget) void handleForget(workspaceToForget); - }} - > - {t("workspaces.forget.short")} - - - - - - ); -}; - -const WorkspaceRailLoading: React.FC = () => { - const { t } = useTranslation(); - return ( -
- - -
- ); -}; diff --git a/apps/dashboard/src/features/workspace-settings/WorkspaceEnvironmentSettings.tsx b/apps/dashboard/src/features/workspace-settings/WorkspaceEnvironmentSettings.tsx index 9260c20..0b70fb9 100644 --- a/apps/dashboard/src/features/workspace-settings/WorkspaceEnvironmentSettings.tsx +++ b/apps/dashboard/src/features/workspace-settings/WorkspaceEnvironmentSettings.tsx @@ -23,7 +23,6 @@ import { import { SessionStatus } from "@/features/infisical-session/AccountSettings"; import type { WorkspaceEnvironmentPatch } from "@/types/api"; export function WorkspaceEnvironmentSettings({ - currentBackend, environment, workspaceEntryId, readOnly = false, @@ -47,7 +46,7 @@ export function WorkspaceEnvironmentSettings({ ); const stage = useManifestDraftStore((s) => s.stageWorkspaceSection); const initial: WorkspaceEnvironmentPatch = { - backend: settings.data?.backend ?? currentBackend ?? "dotenv", + backend: "infisical", ...(settings.data?.projectId ? { projectId: settings.data.projectId, @@ -92,21 +91,7 @@ export function WorkspaceEnvironmentSettings({
- +

Infisical

{value.backend === "infisical" ? (
diff --git a/apps/dashboard/src/lib/stores/toast.ts b/apps/dashboard/src/lib/stores/toast.ts deleted file mode 100644 index 82723d6..0000000 --- a/apps/dashboard/src/lib/stores/toast.ts +++ /dev/null @@ -1,224 +0,0 @@ -import Toast, { type ToastOptions, type ToastType } from "../toast"; -import { createStore } from "../utils"; - -// Toast历史记录接口 -export interface ToastHistoryItem { - id: string; - type: ToastType; - content: string; - timestamp: number; - options?: ToastOptions; -} - -// Toast状态接口 -interface ToastState { - /** Toast历史记录 */ - history: ToastHistoryItem[]; - /** 当前活跃的Toast数量 */ - activeCount: number; - /** 是否启用Toast历史记录 */ - enableHistory: boolean; - /** 最大历史记录数量 */ - maxHistoryCount: number; - - // 显示方法 - success: (content: string, options?: ToastOptions) => string; - info: (content: string, options?: ToastOptions) => string; - warning: (content: string, options?: ToastOptions) => string; - error: (content: string, options?: ToastOptions) => string; - loading: (content: string, options?: ToastOptions) => string; - - // 便捷方法 - successAction: (action: string, options?: ToastOptions) => string; - errorAction: (action: string, reason?: string, options?: ToastOptions) => string; - loadingAction: (action: string, options?: ToastOptions) => string; - copySuccess: (options?: ToastOptions) => string; - networkError: (options?: ToastOptions) => string; - permissionDenied: (options?: ToastOptions) => string; - - // 管理方法 - update: ( - key: string, - content: string, - type?: ToastType, - options?: Omit, - ) => void; - destroy: (key?: string) => void; - clearHistory: () => void; - setEnableHistory: (enable: boolean) => void; - setMaxHistoryCount: (count: number) => void; - getHistoryByType: (type: ToastType) => ToastHistoryItem[]; - getTodayHistory: () => ToastHistoryItem[]; -} - -// 生成唯一ID -const generateId = () => Math.random().toString(36).substring(2, 15); - -// 添加到历史记录 -const addToHistory = ( - get: () => ToastState, - set: (partial: Partial) => void, - type: ToastType, - content: string, - options?: ToastOptions, -) => { - const state = get(); - if (!state.enableHistory) return; - - const historyItem: ToastHistoryItem = { - id: generateId(), - type, - content, - timestamp: Date.now(), - options, - }; - - const newHistory = [historyItem, ...state.history].slice(0, state.maxHistoryCount); - - set({ history: newHistory }); -}; - -/** - * Toast状态管理Store - */ -export const useToastStore = createStore( - (set, get) => ({ - history: [], - activeCount: 0, - enableHistory: true, - maxHistoryCount: 100, - - success: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "success", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.success(content, finalOptions); - return id; - }, - - info: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "info", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.info(content, finalOptions); - return id; - }, - - warning: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "warning", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.warning(content, finalOptions); - return id; - }, - - error: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "error", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.error(content, finalOptions); - return id; - }, - - loading: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "loading", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.loading(content, finalOptions); - return id; - }, - - successAction: (action: string, options?: ToastOptions) => { - return get().success(`${action}成功!`, options); - }, - - errorAction: (action: string, reason?: string, options?: ToastOptions) => { - const content = reason ? `${action}失败:${reason}` : `${action}失败!`; - return get().error(content, options); - }, - - loadingAction: (action: string, options?: ToastOptions) => { - return get().loading(`${action}中...`, options); - }, - - copySuccess: (options?: ToastOptions) => { - return get().success("复制成功!", { duration: 2, ...options }); - }, - - networkError: (options?: ToastOptions) => { - return get().error("网络连接失败,请检查网络设置", { - duration: 5, - ...options, - }); - }, - - permissionDenied: (options?: ToastOptions) => { - return get().warning("权限不足,请联系管理员", { - duration: 4, - ...options, - }); - }, - - update: ( - key: string, - content: string, - type: ToastType = "info", - options?: Omit, - ) => { - Toast.update(key, content, type, options); - }, - - destroy: (key?: string) => { - if (key) { - set({ activeCount: Math.max(0, get().activeCount - 1) }); - } else { - set({ activeCount: 0 }); - } - Toast.destroy(key); - }, - - clearHistory: () => { - set({ history: [] }); - }, - - setEnableHistory: (enable: boolean) => { - set({ enableHistory: enable }); - }, - - setMaxHistoryCount: (count: number) => { - set({ maxHistoryCount: Math.max(1, count) }); - const { history } = get(); - if (history.length > count) { - set({ history: history.slice(0, count) }); - } - }, - - getHistoryByType: (type: ToastType) => { - return get().history.filter((item) => item.type === type); - }, - - getTodayHistory: () => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const todayTimestamp = today.getTime(); - - return get().history.filter((item) => item.timestamp >= todayTimestamp); - }, - }), - "toastStore", -); diff --git a/apps/dashboard/src/lib/toast.ts b/apps/dashboard/src/lib/toast.ts index 7809dd6..4620be9 100644 --- a/apps/dashboard/src/lib/toast.ts +++ b/apps/dashboard/src/lib/toast.ts @@ -216,7 +216,3 @@ const Toast = { }; export default Toast; - -// 导出常用方法的简写 -export const toast = Toast; -export const { success, info, warning, error, loading } = Toast; diff --git a/apps/dashboard/src/lib/utils.ts b/apps/dashboard/src/lib/utils.ts index 6406329..e2c2266 100644 --- a/apps/dashboard/src/lib/utils.ts +++ b/apps/dashboard/src/lib/utils.ts @@ -11,33 +11,6 @@ export const createStore = (storeCreator: StateCreator, name: string) => { return create(storeCreator); }; -export const formatDate = (date: string | Date) => { - return new Date(date).toLocaleString("zh-CN"); -}; - -export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export const debounce = void>(func: T, wait: number): T => { - let timeout: NodeJS.Timeout; - return ((...args) => { - clearTimeout(timeout); - timeout = setTimeout(() => func(...args), wait); - }) as T; -}; - -export const throttle = void>(func: T, limit: number): T => { - let inThrottle: boolean; - return ((...args) => { - if (!inThrottle) { - func(...args); - inThrottle = true; - setTimeout(() => { - inThrottle = false; - }, limit); - } - }) as T; -}; - export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index fcfaf7e..e435f18 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -1,6 +1,5 @@ { "sidebar": { - "brand": "local control", "home": "Home", "settings": "Settings", "themeToDark": "Switch to dark mode", @@ -8,25 +7,16 @@ "language": "Language", "languageAuto": "Follow system", "languageZh": "中文", - "languageEn": "English", - "navigation": "Navigation", - "openNavigation": "Open navigation", - "closeNavigation": "Close navigation" + "languageEn": "English" }, "topbar": { - "home": "Workbench", "workspaces": "Home", "settings": "Settings", "profile": "Credential profiles", - "sectionsRoot": "Sections", - "devData": { - "fixtureLiveProfiles": "Fixture Workspace · Live local Profiles" - } + "sectionsRoot": "Sections" }, "manifestDraft": { "saveButton": "Save changes · {{count}}", - "workspaceScope": "Workspace", - "changeCount": "{{count}} changed", "title": "Write these changes to the manifest?", "description": "This updates one.manifest.json in the Workspace. Review the exact changes before continuing.", "currentManifest": "Current manifest", @@ -60,19 +50,12 @@ "loadFailedTitle": "Could not load Workspaces", "loadFailedDescription": "The local Workspace registry could not be read.", "retry": "Retry", - "registryCount": "{{count}} registered Workspaces", "projectCount": "{{count}} Projects", - "currentSession": "This one serve session", - "workspaceId": "Workspace ID", - "idUnavailable": "Not available", "projects": "Projects", - "projectCountUnavailable": "Unavailable", - "projectCountShort": "{{count}} projects", "lastDetected": "Last detected", "emptyTitle": "No Workspaces yet", "emptyDescription": "Run one create to create a Workspace, or run one serve inside an existing Workspace to register it.", "listLabel": "Registered Workspaces", - "registeredOnMachine": "Registered on this machine", "registrationHint": "New Workspaces appear here after you run one serve.", "refresh": "Refresh", "search": "Search name, path or ID…", @@ -121,7 +104,6 @@ } }, "forget": { - "short": "Remove Workspace", "action": "Remove {{name}}", "confirm": "Remove Workspace \"{{name}}\"? This only removes the local registry entry; no project files or remote variables will be deleted.", "done": "Removed {{name}}", @@ -140,14 +122,9 @@ "title": "Workspace not found", "description": "This registry entry no longer exists. Return home to choose another workspace.", "back": "Choose a Workspace" - }, - "noneReady": { - "title": "No available Workspaces", - "description": "Select an unavailable Workspace in the left rail for repair guidance, or run one serve inside a valid Workspace." } }, "notFound": { - "message": "404 - Page not found", "title": "Page not found", "description": "This address does not match a dashboard page. Return home to continue." }, @@ -159,12 +136,6 @@ "reload": "Reload", "home": "Back home" }, - "settings": { - "title": "Settings", - "description": "Manage the machine-level credential profiles available to your Workspaces and Projects.", - "loadFailedTitle": "Failed to load Settings", - "manageBackend": "Manage Profiles" - }, "sections": { "groupLabel": { "env": "Environment variables" @@ -179,218 +150,84 @@ "home": { "title": "Machine-level profiles", "loadFailedTitle": "Failed to load profiles", - "profileCount_one": "{{count}} profile", - "profileCount_other": "{{count}} profiles", - "default": "default: {{name}}", - "noDefault": "no default", "loading": "Loading…" }, - "detail": { - "unknownSectionTitle": "Unknown section", - "unknownSectionBody": "{{domain}}/{{backend}} is not a valid (domain, backend) pair.", - "loadFailedTitle": "Load failed", - "showSecrets": "Show secrets", - "hideSecrets": "Hide secrets", - "showingSecrets": "Showing secrets", - "addProfile": "New profile", - "loading": "Loading…", - "empty": "No profiles yet. Click 'New profile' to create the first one.", - "default": "default", - "tableProfile": "Profile", - "tableSummary": "Summary", - "tableActions": "Actions", - "setDefault": "Set default", - "edit": "Edit", - "remove": "Delete", - "confirmRemove": "Delete profile \"{{name}}\"? This cannot be undone." - }, "form": { - "addTitle": "New profile", - "editTitle": "Edit {{name}}", - "addDescription": "Fill in and save. The first profile in a section is set default automatically.", - "editDescription": "Saving overwrites existing values; leave secret fields blank to keep them unchanged.", - "profileName": "Profile name", - "profileNamePlaceholder": "e.g. work / prod / acr-prod", - "setDefaultAfterSave": "Set default after save", - "setDefaultAfterSaveAuto": "Set default after save (this section has no default yet, so it will be set automatically)", "cancel": "Cancel", - "save": "Save", - "fields": { - "siteUrl": "Site URL", - "clientId": "Client ID", - "clientSecret": "Client Secret", - "secretUnchangedPlaceholder": "Leave blank to keep unchanged", - "endpoint": "Endpoint (leave blank for AWS S3 to use SDK defaults)", - "endpointPlaceholder": "https://", - "region": "Region", - "regionPlaceholder": "us-east-1 / cn-hangzhou / auto", - "forcePathStyle": "force path style (MinIO / RustFS need this)", - "accessKeyId": "Access Key ID", - "accessKeySecret": "Access Key Secret", - "kubeconfigPath": "Kubeconfig path", - "kubeconfigContext": "Context name", - "kubeconfigContextPlaceholder": "prod / staging", - "teamSlug": "Team slug (leave blank for personal scope)", - "teamSlugPlaceholder": "my-org", - "apiToken": "API Token", - "accountId": "Account ID (required for multi-account tokens, otherwise leave blank)", - "accountIdPlaceholder": "a1b2c3...", - "regionEdgeOne": "Region (e.g. ap-guangzhou; optional)", - "regionEdgeOnePlaceholder": "ap-guangzhou", - "registry": "Registry", - "registryPlaceholder": "your-harbor.example.com / quay.io / ...", - "acrRegion": "ACR Region", - "acrRegionPlaceholder": "cn-hangzhou / cn-shanghai (host derives to registry..aliyuncs.com)", - "namespace": "Namespace", - "namespacePlaceholder": "org / username", - "username": "Username", - "password": "Password / token" - }, - "summary": { - "noFields": "(no fields)", - "site": "site: {{site}}", - "notSet": "(not set)", - "endpointDefault": "(SDK default)", - "regionDefault": "-", - "endpointRegion": "endpoint: {{endpoint}} · region: {{region}}", - "contextDefault": "-", - "kubeconfigDefault": "(default)", - "contextFile": "context: {{context}} · file: {{file}}", - "team": "team: {{team}}", - "teamPersonal": "(personal)", - "account": "account: {{account}}", - "accountTokenDefault": "(token default)", - "regionLabel": "region: {{region}}", - "regionDefaultLabel": "(default)", - "registryUnset": "(no registry)", - "acrRegionUnset": "(no region)" - }, "close": "Close", "discardTitle": "Discard unsaved changes?", "discardDescription": "Your changes have not been saved. Discard them to close this editor.", "continueEditing": "Keep editing" }, - "toast": { - "updated": "Updated {{name}}", - "created": "Created {{name}}", - "setDefault": "Default switched to {{name}}", - "removed": "Deleted {{name}}", - "setDefaultAfterSaveHint": "Set as default" - }, "http": { "networkError": "Network request failed. Check your connection." }, "projects": { - "title": "Project configuration", - "description": "Review and configure every project declared in the manifest from one place.", "search": "Search project, path, or backend", - "showFilters": "Show filters", - "hideFilters": "Hide filters", - "kindFilter": "Filter by project kind", - "statusFilter": "Filter by resolution status", - "kinds": { - "all": "All kinds" - }, - "statuses": { - "all": "All statuses", - "attention": "Needs attention", - "healthy": "Healthy only" - }, "empty": { - "title": "No matching projects", - "description": "Adjust the search or project-kind filter." - }, - "matrix": { - "project": "Project", - "environment": "Environment", - "status": "Status", - "notApplicable": "-", - "notApplicableTitle": "This configuration does not apply to the project", - "notConfigured": "Not configured", - "profileMissing": "Credentials required", - "setupRequired": "Setup required", - "ready": "Configured", - "healthy": "Healthy", - "issueCount": "{{count}} to resolve", - "configureDomain": "Configure {{domain}} for {{project}}" + "title": "No matching projects" } }, "projectInspector": { - "close": "Close", "workspaceTitle": "Project settings", "projectsTitle": "Projects", - "projectsDescription": "Select a project to configure", "projectListLabel": "Workspace projects", "empty": "This workspace has no projects.", "tabs": { "label": "Project configuration sections", "overview": "Overview", - "environment": "Environment" + "environment": "Environment", + "runtime": "Run" }, "loadFailed": "Unable to load project configuration", "retry": "Retry", - "backend": "Backend", "manifestReadOnly": "Manifest · read-only", "manifestDraft": "Manifest draft", - "saved": "Project Profile binding saved", - "saveFailed": "Could not save the project Profile binding", - "values": { - "enabled": "Enabled", - "disabled": "Disabled" - }, "unsavedChangesTitle": "Discard unsaved changes?", "unsavedChanges": "You have unsaved changes. Discard them and continue?", "discardAndContinue": "Discard and continue", "general": { - "title": "Project overview", - "description": "Edit runtime project settings here. Changes remain a draft until you save them from the top bar.", "template": "Template", "toolchain": "Toolchain", "path": "Project path", "buildVersion": "Build version", "packageManager": "Package manager", - "packageManagerAutomatic": "Detect automatically", "devCommand": "Development command", "buildCommand": "Build command", - "buildSource": "one build reads {{source}}. Edit that file to change the build task.", + "buildSource": "one build reads {{source}}. Edit that file to change the build task. Workspace dependencies and caching are managed by mise; one run build uses the same entry point.", "buildMissing": "No build task configured", "buildUnavailable": "Unable to read build configuration", "buildUnsupported": "No build task is available for this project.", "metadata": "Project information", - "runtime": "Development and build" + "runtime": "Development and build", + "tasks": "Configured tasks", + "tasksUnavailable": "Task configuration could not be read. Check it with one run.", + "tasksEmpty": "No project tasks are configured.", + "taskSource": "Source: {{source}}", + "taskCacheOn": "Artifact caching is configured.", + "taskCacheOff": "Artifact caching is not configured.", + "taskDepends": "Dependencies: {{tasks}}", + "taskOutputs": "Outputs: {{outputs}}", + "devURL": "Development URL (optional)", + "devURLHint": "Leave empty to detect from logs. Use a local HTTP(S) URL without credentials or query parameters." }, "environment": { "title": "Environment configuration", - "description": "Project path, inheritance, and disable settings are staged as Manifest overrides. The environment Backend and Profile are managed at Workspace level; Infisical values can be managed below.", - "path": "Environment file path", + "path": "Infisical folder path", "inherits": "Inherit workspace environment", "inheritsHint": "Project variables override workspace variables with the same name.", "disabled": "Disable project environment", "disabledHint": "Do not inject environment variables into project commands.", "keys": "Declared variable names" }, - "profile": { - "label": "Project profile", - "description": "Choose machine-level credentials scoped to this project and environment.", - "notRequired": "This backend does not require a credential profile.", - "loading": "Loading credential profiles…", - "loadFailed": "Could not load credential profiles.", - "automatic": "Resolve automatically (workspace / default)", - "inherited": "Inherit {{name}} ({{source}})", - "none": "No profile currently resolves", - "manage": "Manage in Settings" - }, "draftHint": "Changes are kept in a draft. Review and save them to apply." }, "secrets": { "title": "Infisical secrets", - "description": "Manage values stored remotely for the selected environment and scope.", "unavailableTitle": "Remote secrets are not enabled", - "unavailableDescription": "The current environment Backend is not Infisical. Switch it to manage remote secrets here.", - "configureBackend": "Configure environment Backend", + "unavailableDescription": "Bind an Infisical project in workspace settings to manage remote variables.", "scope": "Secret scope", "workspaceScope": "Workspace shared", - "directOnly": "Only values defined directly in this folder are editable; inherited values stay untouched.", "add": "Add secret", "key": "Key", "value": "Value", @@ -427,121 +264,26 @@ }, "project": { "fields": { - "bucket": "Bucket", - "environment": "Deployment environment", - "projectId": "Project ID", - "projectName": "Project name", - "workerName": "Worker name" + "environment": "Deployment environment" } }, "overview": { - "untitledWorkspace": "Untitled workspace", - "environments": "Environments", "projects": "Projects", - "empty": "The manifest declares no sub-projects.", - "allGood": "✓ All set", - "workspaceIssuesTitle": "Workspace-level configuration missing", - "manageProfilesCta": "Manage credential profiles", - "profileSafety": "Profiles stay in machine-local configuration; credentials never enter the manifest.", "workspaceEnv": { "title": "Workspace environment", - "scope": "Workspace scope", - "readOnly": "Read-only", "description": "Configure workspace environment storage and manage shared secrets.", - "localBinding": "Machine-local Profile binding", - "manifestLegend": "Shared manifest", - "localLegend": "This machine", "backend": "Backend", - "backendDescription": "Controls where one env reads variables. A switch is staged for review before publication.", - "backendManifest": "Manifest setting", "backendPending": "Pending review", - "backendSource": "Backend changes are reviewed before they are written to one.manifest.json. Existing secret data is not migrated.", - "backendPendingHint": "This is now in Pending changes above. When saved, One CLI initializes the Backend; then you can select its machine Profile.", - "backendMissing": "Not configured", - "loading": "Loading workspace Profile binding…", - "loadFailed": "Could not load the workspace Profile binding.", - "missingBackendCli": "Choose an environment Backend before selecting a Profile.", - "saved": "Workspace Profile saved", - "saveFailed": "Could not save the workspace Profile", - "profile": { - "title": "Credential Profile", - "label": "Profile", - "description": "Choose a machine-local credential Profile for this workspace and environment.", - "localBadge": "This machine", - "waitTitle": "Apply the Backend change first", - "waitDescription": "After the target Backend is active, its Profile requirements are loaded again. This prevents saving a Profile against the wrong Backend.", - "notRequired": "This backend does not require a credential profile.", - "loading": "Loading credential profiles…", - "loadFailed": "Could not load credential profiles.", - "automatic": "Resolve automatically (machine default)", - "inherited": "Automatic · {{name}} ({{source}})", - "none": "No Profile currently resolves", - "manage": "Manage in Settings" - }, + "backendSource": "Infisical project binding changes are reviewed before being written to one.manifest.json.", "signInHint": "Sign in to Infisical in Settings to choose a storage project.", "noProjects": "No storage projects available. Create one from Shared credentials." }, "tabs": { - "label": "Workspace sections", "environment": "Workspace environment", - "projects": "Projects", "secrets": "Infisical secrets" }, "navigation": { - "label": "Workspace page navigation", - "overview": "Attention", - "projects": "Projects", - "secrets": "Secrets", - "settings": "Workspace settings", - "attention": "{{count}} items to resolve", - "ready": "No blocking items", - "projectCount": "{{count}} projects", - "infisical": "Infisical enabled", - "notEnabled": "Remote secrets not enabled", - "backendProfile": "Backend and Profile" - }, - "actionCenter": { - "eyebrow": "Next actions", - "pending": "items to resolve", - "clear": "blocking items", - "description": "Resolve these items in order before running the corresponding One CLI commands.", - "clearDescription": "The workspace's core configuration is ready to use.", - "title": "Resolve first", - "hint": "Each item opens the exact configuration that needs attention.", - "viewProjects": "View all projects", - "workspaceLabel": "Workspace configuration", - "configureWorkspace": "Configure workspace", - "resolve": "Resolve now", - "allGood": "Nothing needs attention", - "allGoodDescription": "Continue managing secrets or inspect detailed project settings." - }, - "command": { - "ready": "ready", - "review": "review setup" - }, - "metrics": { - "projects": "Projects", - "healthy": "Healthy", - "attention": "Attention" - }, - "kinds": { - "app": "App", - "service": "Service", - "package": "Package" - }, - "issue": { - "label": { - "env": "env", - "profile": "profile" - }, - "missingProfile": "{{section}} is missing a usable credential profile{{profile}}. Fill in the API key / token.", - "missing": { - "env": "Workspace has no env backend selected. Set one before using `one env`." - } - }, - "fix": { - "profileCta": "Add credentials", - "cliHint": "Configure this Backend with One CLI." + "settings": "Workspace settings" } }, "session": { @@ -562,7 +304,11 @@ "settingsHint": "Manage your connected account and dashboard preferences.", "connected": "Connected", "account": "Account", - "languageHint": "Choose a language or follow your system setting." + "languageHint": "Choose a language or follow your system setting.", + "theme": "Appearance", + "themeHint": "Choose a light or dark theme. Your choice is saved in this browser.", + "themeLight": "Light", + "themeDark": "Dark" }, "global": { "title": "Shared credentials", @@ -618,5 +364,235 @@ "noMatches": "No matching variables", "noMatchesHint": "Try another name or clear the search.", "emptyHint": "Add a variable to share it across your projects." + }, + "projectCreate": { + "title": "New project", + "description": "Create a project from a template in this workspace.", + "name": "Project name", + "nameHint": "Start with a letter or number. Use letters, numbers, hyphens or underscores.", + "nameInvalid": "Enter a name starting with a letter or number, using only letters, numbers, hyphens or underscores.", + "nameExists": "A project with this name already exists in the workspace.", + "template": "Technology stack or empty project", + "templatesFailed": "Could not load templates", + "loadingTemplates": "Loading templates…", + "noTemplates": "No templates available", + "location": "Project directory", + "locationHint": "Relative to this workspace. Creating the project also updates the workspace configuration.", + "submit": "Create project", + "creating": "Creating project…", + "failed": "Could not create project", + "success": "Project {{name}} created", + "refreshFailed": "Project created. Refresh the page to load the updated workspace.", + "connectionLost": "The connection was interrupted. Refresh the project list to check whether the project was created before trying again.", + "categories": { + "frontend": "Applications", + "backend": "Services", + "library": "Libraries" + }, + "templates": { + "nestjs-api": { + "name": "NestJS API service", + "description": "NestJS + TypeScript for APIs and backend services" + }, + "go-api": { + "name": "Go API service", + "description": "Gin + Gorm + Viper + Zap for APIs and backend services" + }, + "astro-site": { + "name": "Astro static site", + "description": "Static site template for company, marketing, and content websites" + }, + "starlight-docs": { + "name": "Starlight documentation", + "description": "Documentation project based on Astro + Starlight" + }, + "nextjs-app": { + "name": "Next.js application", + "description": "Next.js application for SEO and fast initial page loads" + }, + "react-spa": { + "name": "React single-page application", + "description": "React + Vite template for interactive applications" + }, + "expo-mobile": { + "name": "Expo mobile app", + "description": "React Native + Expo template for iOS and Android" + }, + "ts-library": { + "name": "TypeScript library", + "description": "Publishable library with tsdown, vitest, and oxlint/oxfmt" + }, + "go-lib": { + "name": "Go library", + "description": "Go library following golang-standards/project-layout, ready to publish and import" + }, + "electron-app": { + "name": "Electron desktop app", + "description": "Electron + React + Vite with inversify, electron-log, electron-store, and electron-updater" + }, + "empty-app": { + "name": "Empty application", + "description": "Create an empty project under apps/ and configure a technology stack later." + }, + "empty-service": { + "name": "Empty service", + "description": "Create an empty project under services/ and configure a technology stack later." + }, + "empty-library": { + "name": "Empty library", + "description": "Create an empty project under packages/ and configure a technology stack later." + } + } + }, + "appMenu": { + "label": "One CLI navigation menu" + }, + "templateCatalog": { + "title": "Project templates", + "description": "Explore the built-in starters and compare their use cases, technology stacks, and included features.", + "getStarted": "Start a project from a template", + "getStartedHint": "Open a workspace, click New project, then choose a template and enter a project name.", + "chooseWorkspace": "Choose a workspace", + "filter": "Filter templates by category", + "all": "All templates", + "search": "Search templates, stacks, or features…", + "resultCount": "Showing {{count}} of {{total}} templates", + "includes": "Included features", + "noResults": "No matching templates", + "noResultsHint": "Try another search term or select a different category.", + "reset": "Clear filters", + "templates": { + "nestjs-api": { + "description": "Build TypeScript APIs and business backends with a modular NestJS structure.", + "stack": "NestJS · TypeScript · Drizzle", + "feature1": "JWT authentication and user modules", + "feature2": "Drizzle data access and database migrations", + "feature3": "Swagger documentation, health checks, and metrics" + }, + "go-api": { + "description": "Build Go APIs with separate HTTP, service, and data-access layers.", + "stack": "Go · Gin · Gorm · Viper · Zap", + "feature1": "JWT authentication and user endpoints", + "feature2": "Gorm data access and SQL migrations", + "feature3": "OpenAPI documentation, configuration, and logging" + }, + "astro-site": { + "description": "Create company websites, landing pages, and content sites with static page output.", + "stack": "Astro · TypeScript · Tailwind CSS", + "feature1": "File-based pages and shared layouts", + "feature2": "Shared design tokens and Tailwind styles", + "feature3": "Type checking, linting, and formatting" + }, + "starlight-docs": { + "description": "Publish product documentation, developer guides, and team knowledge bases.", + "stack": "Astro · Starlight · Markdown / MDX", + "feature1": "Markdown and MDX documentation pages", + "feature2": "Guide and reference sections with a configured sidebar", + "feature3": "Customizable documentation theme" + }, + "nextjs-app": { + "description": "Build web applications with server rendering, API routes, and search-friendly pages.", + "stack": "Next.js · React · TypeScript", + "feature1": "App Router pages and route handlers", + "feature2": "shadcn/ui components and theme switching", + "feature3": "SWR data fetching and Zustand state management" + }, + "react-spa": { + "description": "Create interactive dashboards, admin panels, and browser-based business applications.", + "stack": "React · Vite · TypeScript", + "feature1": "Client-side routing and error boundaries", + "feature2": "shadcn/ui components and light / dark themes", + "feature3": "SWR data fetching and Zustand state management" + }, + "expo-mobile": { + "description": "Build iOS and Android apps with React Native and a shared Expo project.", + "stack": "Expo · React Native · NativeWind", + "feature1": "Expo Router navigation and tab pages", + "feature2": "SWR, Zustand, and MMKV local storage", + "feature3": "Native modules use an Expo development build" + }, + "ts-library": { + "description": "Create reusable TypeScript modules and packages for publishing to npm.", + "stack": "TypeScript · tsdown · Vitest", + "feature1": "Library builds and type declarations with tsdown", + "feature2": "Unit tests and coverage with Vitest", + "feature3": "Linting, formatting, and publishing scripts" + }, + "go-lib": { + "description": "Create reusable Go modules with a public package layout for other projects to import.", + "stack": "Go · Go modules · go-task", + "feature1": "Public API starter in pkg/greeter", + "feature2": "Example package and unit tests", + "feature3": "Build, format, vet, and test tasks" + }, + "electron-app": { + "description": "Create desktop applications with an Electron main process and a React user interface.", + "stack": "Electron · React · Vite · Inversify", + "feature1": "Separate main, renderer, and preload packages", + "feature2": "Typed IPC bridge and dependency injection", + "feature3": "Logging, local settings, and updater modules" + }, + "empty-app": { + "description": "Create an empty project under apps/ and configure a technology stack later.", + "stack": "Choose your own technology stack", + "feature1": "Only a .gitkeep file for Git tracking", + "feature2": "Registered in the workspace", + "feature3": "Configure a development command after adding code" + }, + "empty-service": { + "description": "Create an empty project under services/ and configure a technology stack later.", + "stack": "Choose your own technology stack", + "feature1": "Only a .gitkeep file for Git tracking", + "feature2": "Registered in the workspace", + "feature3": "Configure a development command after adding code" + }, + "empty-library": { + "description": "Create an empty project under packages/ and configure a technology stack later.", + "stack": "Choose your own technology stack", + "feature1": "Only a .gitkeep file for Git tracking", + "feature2": "Registered in the workspace", + "feature3": "Configure a development command after adding code" + } + }, + "noToolchain": "Not configured" + }, + "services": { + "title": "Development service", + "description": "Start the project, view its output, and open it for testing.", + "start": "Start service", + "stop": "Stop", + "restart": "Restart", + "open": "Open", + "console": "Console", + "live": "Connected", + "reconnecting": "Connecting…", + "search": "Search logs", + "pause": "Pause scrolling", + "follow": "Follow logs", + "copy": "Copy", + "copied": "Logs copied", + "copyFailed": "Could not copy. Select and copy text in the console.", + "actionFailed": "Action failed. Try again.", + "saveFirst": "Save or discard pending configuration changes before starting or restarting.", + "noDev": "No dev task is configured. Set a dev command in Overview or add a dev task to the project’s mise.toml.", + "environmentMismatch": "This service uses {{environment}}. Restart to use the selected {{next}} environment.", + "exitCode": "Exit code: {{code}}", + "reachable": "Local port is reachable", + "unreachable": "Local port is not reachable", + "waitingURL": "Waiting for a local URL in the output. Services without a web page can still run.", + "urlHint": "Local URLs are detected after startup. You can also set one in Overview.", + "waitingLogs": "Preparing the runtime. Waiting for output…", + "emptyLogs": "Start a service to see its output here.", + "noMatches": "No matching logs.", + "consoleHint": "Logs are redacted; up to 256 KiB is retained. Closing this page keeps services running; closing one serve stops them.", + "truncated": "Older logs were removed. Only recent output is shown.", + "status": { + "preparing": "Preparing", + "running": "Running", + "stopping": "Stopping", + "stopped": "Stopped", + "exited": "Exited", + "failed": "Failed" + } } } diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index 218943a..54114b3 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -1,6 +1,5 @@ { "sidebar": { - "brand": "local control", "home": "首页", "settings": "设置", "themeToDark": "切深色", @@ -8,25 +7,16 @@ "language": "语言", "languageAuto": "跟随系统", "languageZh": "中文", - "languageEn": "English", - "navigation": "导航", - "openNavigation": "打开导航", - "closeNavigation": "关闭导航" + "languageEn": "English" }, "topbar": { - "home": "工作台", "workspaces": "主页", "settings": "设置", "profile": "凭据 profile", - "sectionsRoot": "配置分区", - "devData": { - "fixtureLiveProfiles": "Fixture Workspace · 本机真实 Profile" - } + "sectionsRoot": "配置分区" }, "manifestDraft": { "saveButton": "保存更改 · {{count}}", - "workspaceScope": "Workspace", - "changeCount": "{{count}} 项修改", "title": "要把这些修改写入 Manifest 吗?", "description": "这会修改工作区里的 one.manifest.json。继续前请确认下面的具体差异。", "currentManifest": "当前 Manifest", @@ -60,19 +50,12 @@ "loadFailedTitle": "无法加载工作区", "loadFailedDescription": "无法读取本机工作区注册表。", "retry": "重试", - "registryCount": "{{count}} 个工作区", "projectCount": "{{count}} 个项目", - "currentSession": "本次 one serve", - "workspaceId": "Workspace ID", - "idUnavailable": "不可用", "projects": "项目", - "projectCountUnavailable": "不可用", - "projectCountShort": "{{count}} 个项目", "lastDetected": "最近识别", "emptyTitle": "尚未登记工作区", "emptyDescription": "运行 one create 创建工作区,或在已有工作区中运行 one serve 进行登记。", "listLabel": "已登记的工作区", - "registeredOnMachine": "已登记到本机", "registrationHint": "运行 one serve 后,新的 Workspace 会显示在这里。", "refresh": "刷新", "search": "搜索名称、路径或 ID…", @@ -121,7 +104,6 @@ } }, "forget": { - "short": "移除 Workspace", "action": "移除 {{name}}", "confirm": "要移除 Workspace「{{name}}」吗?这里只会移除本机注册记录,不会删除项目文件或远端变量。", "done": "已移除 {{name}}", @@ -140,14 +122,9 @@ "title": "找不到 Workspace", "description": "这条登记记录已不存在,请返回首页选择其他工作区。", "back": "选择 Workspace" - }, - "noneReady": { - "title": "没有可用的 Workspace", - "description": "可在左侧选择异常 Workspace 查看修复提示,或进入有效 Workspace 执行 one serve。" } }, "notFound": { - "message": "404 - 页面未找到", "title": "页面不存在", "description": "当前地址没有对应的管理页面,请返回首页继续。" }, @@ -159,12 +136,6 @@ "reload": "重新加载", "home": "返回首页" }, - "settings": { - "title": "设置", - "description": "管理可供 Workspace 和 Project 使用的机器级凭据 Profile。", - "loadFailedTitle": "加载设置失败", - "manageBackend": "管理 Profile" - }, "sections": { "groupLabel": { "env": "环境变量" @@ -179,218 +150,84 @@ "home": { "title": "机器级 profile", "loadFailedTitle": "加载 profile 失败", - "profileCount_one": "{{count}} 个 profile", - "profileCount_other": "{{count}} 个 profile", - "default": "default: {{name}}", - "noDefault": "无 default", "loading": "加载中…" }, - "detail": { - "unknownSectionTitle": "未知 section", - "unknownSectionBody": "{{domain}}/{{backend}} 不是合法 (domain, backend) 对。", - "loadFailedTitle": "加载失败", - "showSecrets": "显示原文", - "hideSecrets": "隐藏凭据原文", - "showingSecrets": "已显示原文", - "addProfile": "新增 profile", - "loading": "加载中…", - "empty": "还没有 profile。点击右上「新增 profile」创建第一个。", - "default": "default", - "tableProfile": "Profile", - "tableSummary": "摘要", - "tableActions": "操作", - "setDefault": "设为 default", - "edit": "编辑", - "remove": "删除", - "confirmRemove": "确认删除 profile \"{{name}}\"?此操作不可撤销。" - }, "form": { - "addTitle": "新增 profile", - "editTitle": "编辑 {{name}}", - "addDescription": "填好后点保存。第一个 profile 自动成为 default。", - "editDescription": "保存会覆盖现有内容;密钥字段留空会保持不变。", - "profileName": "profile 名", - "profileNamePlaceholder": "如 work / prod / acr-prod", - "setDefaultAfterSave": "保存后设为 default", - "setDefaultAfterSaveAuto": "保存后设为 default(当前 section 无 default,会自动设)", "cancel": "取消", - "save": "保存", - "fields": { - "siteUrl": "Site URL", - "clientId": "Client ID", - "clientSecret": "Client Secret", - "secretUnchangedPlaceholder": "留空则保持不变", - "endpoint": "Endpoint(aws-s3 可留空走 SDK 默认)", - "endpointPlaceholder": "https://", - "region": "Region", - "regionPlaceholder": "us-east-1 / cn-hangzhou / auto", - "forcePathStyle": "force path style(MinIO / RustFS 必需)", - "accessKeyId": "Access Key ID", - "accessKeySecret": "Access Key Secret", - "kubeconfigPath": "Kubeconfig 路径", - "kubeconfigContext": "Context 名", - "kubeconfigContextPlaceholder": "prod / staging", - "teamSlug": "Team slug(留空 = 个人 scope)", - "teamSlugPlaceholder": "my-org", - "apiToken": "API Token", - "accountId": "Account ID(多账号 token 必填,否则留空)", - "accountIdPlaceholder": "a1b2c3...", - "regionEdgeOne": "Region(如 ap-guangzhou,可空)", - "regionEdgeOnePlaceholder": "ap-guangzhou", - "registry": "Registry", - "registryPlaceholder": "your-harbor.example.com / quay.io / ...", - "acrRegion": "ACR Region", - "acrRegionPlaceholder": "cn-hangzhou / cn-shanghai(host 派生为 registry..aliyuncs.com)", - "namespace": "Namespace", - "namespacePlaceholder": "org / username", - "username": "Username", - "password": "Password / token" - }, - "summary": { - "noFields": "(无字段)", - "site": "site: {{site}}", - "notSet": "(未填)", - "endpointDefault": "(SDK 默认)", - "regionDefault": "-", - "endpointRegion": "endpoint: {{endpoint}} · region: {{region}}", - "contextDefault": "-", - "kubeconfigDefault": "(默认)", - "contextFile": "context: {{context}} · file: {{file}}", - "team": "team: {{team}}", - "teamPersonal": "(personal)", - "account": "account: {{account}}", - "accountTokenDefault": "(token default)", - "regionLabel": "region: {{region}}", - "regionDefaultLabel": "(default)", - "registryUnset": "(未填 registry)", - "acrRegionUnset": "(未填 region)" - }, "close": "关闭", "discardTitle": "放弃未保存的修改?", "discardDescription": "当前修改尚未保存。放弃后将关闭编辑窗口。", "continueEditing": "继续编辑" }, - "toast": { - "updated": "已更新 {{name}}", - "created": "已新增 {{name}}", - "setDefault": "default 已切换到 {{name}}", - "removed": "已删除 {{name}}", - "setDefaultAfterSaveHint": "已设为 default" - }, "http": { "networkError": "网络请求失败,请检查网络连接。" }, "projects": { - "title": "项目配置矩阵", - "description": "从一处查看并配置 manifest 中的全部项目。", "search": "搜索项目、路径或 backend", - "showFilters": "显示筛选", - "hideFilters": "收起筛选", - "kindFilter": "按项目类型筛选", - "statusFilter": "按处理状态筛选", - "kinds": { - "all": "全部类型" - }, - "statuses": { - "all": "全部状态", - "attention": "仅待处理", - "healthy": "仅正常" - }, "empty": { - "title": "没有匹配的项目", - "description": "调整搜索词或项目类型筛选。" - }, - "matrix": { - "project": "项目", - "environment": "Environment", - "status": "状态", - "notApplicable": "-", - "notApplicableTitle": "这个项目不适用该配置", - "notConfigured": "未配置", - "profileMissing": "凭据待补充", - "setupRequired": "需要配置", - "ready": "配置可用", - "healthy": "正常", - "issueCount": "{{count}} 项待处理", - "configureDomain": "为 {{project}} 配置 {{domain}}" + "title": "没有匹配的项目" } }, "projectInspector": { - "close": "关闭项目配置", "workspaceTitle": "项目设置", "projectsTitle": "项目", - "projectsDescription": "选择项目后在右侧配置", "projectListLabel": "工作区项目", "empty": "这个工作区还没有项目。", "tabs": { "label": "项目配置分区", "overview": "概览", - "environment": "环境" + "environment": "环境", + "runtime": "运行" }, "loadFailed": "无法加载项目配置", "retry": "重试", - "backend": "Backend", "manifestReadOnly": "Manifest · 只读", "manifestDraft": "Manifest 草稿", - "saved": "项目 Profile 绑定已保存", - "saveFailed": "保存项目 Profile 绑定失败", - "values": { - "enabled": "已启用", - "disabled": "已禁用" - }, "unsavedChangesTitle": "放弃未保存的修改?", "unsavedChanges": "当前修改尚未保存。要放弃修改并继续吗?", "discardAndContinue": "放弃并继续", "general": { - "title": "项目概览", - "description": "在这里修改项目运行配置;点击右上角保存前,所有变化只保留为草稿。", "template": "Template", "toolchain": "Toolchain", "path": "项目路径", "buildVersion": "构建版本", "packageManager": "包管理器", - "packageManagerAutomatic": "自动检测", "devCommand": "开发命令", "buildCommand": "构建命令", - "buildSource": "one build 自动读取 {{source}},请在该文件中修改构建任务。", + "buildSource": "one build 自动读取 {{source}},请在该文件中修改构建任务。 工作区依赖与缓存由 mise 管理,one run build 使用相同入口。", "buildMissing": "未配置构建任务", "buildUnavailable": "无法读取构建配置", "buildUnsupported": "此项目没有可用的构建任务。", "metadata": "项目信息", - "runtime": "开发与构建" + "runtime": "开发与构建", + "tasks": "已配置任务", + "tasksUnavailable": "无法读取任务配置,请使用 one run 检查。", + "tasksEmpty": "此项目没有配置任务。", + "taskSource": "来源:{{source}}", + "taskCacheOn": "已配置产物缓存。", + "taskCacheOff": "未配置产物缓存。", + "taskDepends": "依赖:{{tasks}}", + "taskOutputs": "产物:{{outputs}}", + "devURL": "开发服务访问地址(可选)", + "devURLHint": "留空时从日志自动识别。仅支持不含凭据及查询参数的本机 HTTP(S) 地址。" }, "environment": { "title": "环境配置", - "description": "项目路径、继承和禁用设置会作为 Manifest 覆盖项进入草稿;环境 Backend 和 Profile 统一在 Workspace 级管理,Infisical 密钥可在下方直接配置。", - "path": "环境文件路径", + "path": "Infisical 文件夹路径", "inherits": "继承工作区环境", "inheritsHint": "项目变量会覆盖同名工作区变量。", "disabled": "禁用项目环境", "disabledHint": "运行项目命令时不注入环境变量。", "keys": "已声明变量名" }, - "profile": { - "label": "项目 Profile", - "description": "选择仅对这个项目和当前环境生效的机器级凭据。", - "notRequired": "这个 Backend 不需要凭据 Profile。", - "loading": "正在加载凭据 Profile…", - "loadFailed": "无法加载凭据 Profile。", - "automatic": "自动解析(工作区 / 默认)", - "inherited": "继承 {{name}}({{source}})", - "none": "尚未解析到 Profile", - "manage": "前往设置管理" - }, "draftHint": "修改将暂存为草稿,预览并保存后生效。" }, "secrets": { "title": "Infisical 密钥", - "description": "管理当前环境和作用域中保存于远端的密钥值。", "unavailableTitle": "远端密钥尚未启用", - "unavailableDescription": "当前环境 Backend 不是 Infisical;切换后可在这里管理远端密钥。", - "configureBackend": "配置环境 Backend", + "unavailableDescription": "请在工作区设置中绑定 Infisical 项目,以管理远端变量。", "scope": "密钥作用域", "workspaceScope": "Workspace 共享", - "directOnly": "这里只能修改当前 folder 直接定义的值,不会改动继承的上层密钥。", "add": "新增密钥", "key": "Key", "value": "值", @@ -427,121 +264,26 @@ }, "project": { "fields": { - "bucket": "Bucket", - "environment": "部署环境", - "projectId": "Project ID", - "projectName": "项目名称", - "workerName": "Worker 名称" + "environment": "部署环境" } }, "overview": { - "untitledWorkspace": "未命名 workspace", - "environments": "环境", "projects": "子项目", - "empty": "manifest 里没有声明任何子项目。", - "allGood": "✓ 配置齐全", - "workspaceIssuesTitle": "工作区级配置缺失", - "manageProfilesCta": "管理凭据 profile", - "profileSafety": "Profile 保存在本机配置中,凭据不会写入 manifest。", "workspaceEnv": { "title": "工作区环境变量", - "scope": "Workspace 级", - "readOnly": "只读", "description": "配置工作区环境存储,并管理共享密钥。", - "localBinding": "机器本地 Profile 绑定", - "manifestLegend": "共享 Manifest", - "localLegend": "本机配置", "backend": "Backend", - "backendDescription": "决定 one env 从哪里读取环境变量。切换后会进入待提交改动。", - "backendManifest": "Manifest 配置", "backendPending": "等待审阅", - "backendSource": "Backend 修改会先进入草稿,审阅后写入 one.manifest.json;已有密钥数据不会自动迁移。", - "backendPendingHint": "已加入顶部的待提交改动。保存时 One CLI 会完成 Backend 初始化;完成后即可选择对应的本机 Profile。", - "backendMissing": "未配置", - "loading": "正在加载 Workspace Profile 绑定…", - "loadFailed": "无法加载 Workspace Profile 绑定。", - "missingBackendCli": "请先选择环境变量 Backend,再选择 Profile。", - "saved": "Workspace Profile 已保存", - "saveFailed": "无法保存 Workspace Profile", - "profile": { - "title": "凭据 Profile", - "label": "Profile", - "description": "为这个 Workspace 和当前环境选择一个机器本地的凭据 Profile。", - "localBadge": "仅本机", - "waitTitle": "先应用 Backend 修改", - "waitDescription": "目标 Backend 生效后,系统会重新读取它需要的 Profile;这里不会提前保存到错误的 Backend。", - "notRequired": "这个 Backend 不需要凭据 Profile。", - "loading": "正在加载凭据 Profile…", - "loadFailed": "无法加载凭据 Profile。", - "automatic": "自动解析(机器默认)", - "inherited": "自动 · {{name}}({{source}})", - "none": "当前未解析到 Profile", - "manage": "前往设置管理" - }, + "backendSource": "Infisical 项目绑定变更经审阅后写入 one.manifest.json。", "signInHint": "请先在设置中登录 Infisical,再选择存储项目。", "noProjects": "暂无可用的存储项目,可在共享凭据中创建。" }, "tabs": { - "label": "工作区分区", "environment": "工作区环境变量", - "projects": "项目", "secrets": "Infisical 密钥" }, "navigation": { - "label": "工作区页面导航", - "overview": "待处理", - "projects": "项目", - "secrets": "密钥", - "settings": "工作区设置", - "attention": "{{count}} 项需要处理", - "ready": "当前没有阻塞项", - "projectCount": "{{count}} 个项目", - "infisical": "Infisical 已启用", - "notEnabled": "尚未启用远端密钥", - "backendProfile": "Backend 与 Profile" - }, - "actionCenter": { - "eyebrow": "下一步", - "pending": "项需要处理", - "clear": "项阻塞", - "description": "按顺序处理下面的问题,完成后再运行对应的 One CLI 命令。", - "clearDescription": "工作区的核心配置已经可以使用。", - "title": "优先处理", - "hint": "每一项都直接进入对应的配置位置。", - "viewProjects": "查看全部项目", - "workspaceLabel": "工作区配置", - "configureWorkspace": "配置工作区", - "resolve": "立即处理", - "allGood": "当前没有待处理项", - "allGoodDescription": "可以继续管理密钥,或检查各项目的详细配置。" - }, - "command": { - "ready": "可以运行", - "review": "检查配置" - }, - "metrics": { - "projects": "项目", - "healthy": "正常", - "attention": "待处理" - }, - "kinds": { - "app": "App", - "service": "Service", - "package": "Package" - }, - "issue": { - "label": { - "env": "env", - "profile": "profile" - }, - "missingProfile": "{{section}} 缺少可用凭据 profile{{profile}},请补齐 API key / token。", - "missing": { - "env": "workspace 未选择 env backend,请先配置再运行 `one env` 相关命令。" - } - }, - "fix": { - "profileCta": "补凭据", - "cliHint": "请使用 One CLI 配置这个 Backend。" + "settings": "工作区设置" } }, "session": { @@ -562,7 +304,11 @@ "settingsHint": "管理已连接的账号与界面偏好。", "connected": "已连接", "account": "账号", - "languageHint": "选择显示语言,或跟随系统设置。" + "languageHint": "选择显示语言,或跟随系统设置。", + "theme": "外观主题", + "themeHint": "选择浅色或深色主题,偏好会保存在当前浏览器中。", + "themeLight": "浅色", + "themeDark": "深色" }, "global": { "title": "共享凭据", @@ -618,5 +364,235 @@ "noMatches": "没有匹配的变量", "noMatchesHint": "尝试其他名称,或清除搜索条件。", "emptyHint": "添加变量,让多个项目共享使用。" + }, + "projectCreate": { + "title": "新建项目", + "description": "在当前工作区中,使用模板创建一个项目。", + "name": "项目名称", + "nameHint": "以字母或数字开头,可使用字母、数字、连字符和下划线。", + "nameInvalid": "请输入以字母或数字开头,且仅包含字母、数字、连字符或下划线的名称。", + "nameExists": "当前工作区已存在同名项目,请使用其他名称。", + "template": "技术栈或空项目", + "templatesFailed": "无法加载模板", + "loadingTemplates": "正在加载模板…", + "noTemplates": "暂无可用模板", + "location": "项目目录", + "locationHint": "相对于当前工作区。创建项目时会同步更新工作区配置。", + "submit": "创建项目", + "creating": "正在创建项目…", + "failed": "创建项目失败", + "success": "项目 {{name}} 已创建", + "refreshFailed": "项目已创建,请刷新页面加载最新工作区。", + "connectionLost": "连接已中断,请先刷新项目列表确认是否已创建,再尝试提交。", + "categories": { + "frontend": "应用", + "backend": "服务", + "library": "库" + }, + "templates": { + "nestjs-api": { + "name": "NestJS API 服务", + "description": "NestJS + TypeScript,适合 API 服务与业务后台" + }, + "go-api": { + "name": "Go API 服务", + "description": "Gin + Gorm + Viper + Zap,适合 API 服务与业务后台" + }, + "astro-site": { + "name": "Astro 静态网站", + "description": "前端静态站模板,适合官网、营销站与内容站点" + }, + "starlight-docs": { + "name": "Starlight 文档站点", + "description": "基于 Astro + Starlight 的文档项目模板" + }, + "nextjs-app": { + "name": "Next.js 全栈应用", + "description": "Next.js 前端模板,适合 SEO 与首屏性能要求较高场景" + }, + "react-spa": { + "name": "React 单页应用", + "description": "React + Vite 单页应用模板,适合交互型业务系统" + }, + "expo-mobile": { + "name": "Expo 移动 App", + "description": "React Native + Expo App 模板,适合 iOS/Android 双端开发" + }, + "ts-library": { + "name": "TypeScript 通用库", + "description": "通用库模板,适合发包场景,内置 tsdown + vitest + oxlint/oxfmt" + }, + "go-lib": { + "name": "Go 通用库", + "description": "Go 库模板,遵循 golang-standards/project-layout,适合发包与被 import 场景" + }, + "electron-app": { + "name": "Electron 桌面应用", + "description": "Electron + React + Vite 桌面应用模板,主进程基于 inversify,内置 electron-log/store/updater" + }, + "empty-app": { + "name": "空应用", + "description": "在 apps/ 下创建空项目,稍后自行配置技术栈。" + }, + "empty-service": { + "name": "空服务", + "description": "在 services/ 下创建空项目,稍后自行配置技术栈。" + }, + "empty-library": { + "name": "空共享库", + "description": "在 packages/ 下创建空项目,稍后自行配置技术栈。" + } + } + }, + "appMenu": { + "label": "One CLI 导航菜单" + }, + "templateCatalog": { + "title": "项目模板", + "description": "浏览内置项目模板,了解适用场景、技术栈和内置功能,选择合适的项目起点。", + "getStarted": "从模板开始创建项目", + "getStartedHint": "打开工作区,点击「新建项目」,选择模板并填写项目名称即可创建。", + "chooseWorkspace": "选择工作区", + "filter": "按分类筛选模板", + "all": "全部模板", + "search": "搜索模板、技术栈或功能…", + "resultCount": "显示 {{count}} / {{total}} 个模板", + "includes": "内置功能", + "noResults": "没有匹配的模板", + "noResultsHint": "试试其他关键词,或切换模板分类。", + "reset": "清除筛选", + "templates": { + "nestjs-api": { + "description": "使用模块化 NestJS 架构构建 TypeScript API,适合业务后台与后端服务。", + "stack": "NestJS · TypeScript · Drizzle", + "feature1": "JWT 鉴权与用户业务模块", + "feature2": "Drizzle 数据访问与数据库迁移", + "feature3": "Swagger 文档、健康检查与监控指标" + }, + "go-api": { + "description": "构建 Go API 服务,按 HTTP 接口、业务逻辑和数据访问分层组织代码。", + "stack": "Go · Gin · Gorm · Viper · Zap", + "feature1": "JWT 鉴权与用户接口", + "feature2": "Gorm 数据访问与 SQL 迁移", + "feature3": "OpenAPI 文档、配置管理与日志" + }, + "astro-site": { + "description": "通过静态页面构建官网、营销落地页和内容站点,适合以内容展示为主的项目。", + "stack": "Astro · TypeScript · Tailwind CSS", + "feature1": "基于文件的页面路由与共享布局", + "feature2": "统一设计变量与 Tailwind 样式", + "feature3": "类型检查、代码检查与格式化" + }, + "starlight-docs": { + "description": "快速搭建产品文档、开发者指南和团队知识库,专注于文档内容的编写。", + "stack": "Astro · Starlight · Markdown / MDX", + "feature1": "Markdown 与 MDX 文档页面", + "feature2": "指南、参考文档与预设侧边栏", + "feature3": "可定制的文档主题样式" + }, + "nextjs-app": { + "description": "构建需要服务端渲染、API 路由和搜索引擎友好页面的 Web 应用。", + "stack": "Next.js · React · TypeScript", + "feature1": "App Router 页面与接口路由", + "feature2": "shadcn/ui 组件与主题切换", + "feature3": "SWR 数据请求与 Zustand 状态管理" + }, + "react-spa": { + "description": "构建交互丰富的仪表盘、管理后台和浏览器端业务系统。", + "stack": "React · Vite · TypeScript", + "feature1": "客户端路由与错误边界", + "feature2": "shadcn/ui 组件与明暗主题", + "feature3": "SWR 数据请求与 Zustand 状态管理" + }, + "expo-mobile": { + "description": "基于 React Native 和 Expo,以同一项目开发 iOS 与 Android 移动应用。", + "stack": "Expo · React Native · NativeWind", + "feature1": "Expo Router 导航与标签页面", + "feature2": "SWR、Zustand 与 MMKV 本地存储", + "feature3": "原生模块通过 Expo 开发构建运行" + }, + "ts-library": { + "description": "开发可复用的 TypeScript 模块,适合共享工具库和发布到 npm 的包。", + "stack": "TypeScript · tsdown · Vitest", + "feature1": "tsdown 库构建与类型声明生成", + "feature2": "Vitest 单元测试与覆盖率", + "feature3": "代码检查、格式化与发包脚本" + }, + "go-lib": { + "description": "开发可被其他项目导入的 Go 模块,使用清晰的公共包目录组织可复用代码。", + "stack": "Go · Go modules · go-task", + "feature1": "pkg/greeter 公共 API 起点", + "feature2": "示例包与单元测试", + "feature3": "构建、格式化、静态检查与测试任务" + }, + "electron-app": { + "description": "使用 Electron 主进程和 React 界面构建桌面应用,适合桌面工具与业务客户端。", + "stack": "Electron · React · Vite · Inversify", + "feature1": "主进程、渲染进程与 preload 分包", + "feature2": "带类型的 IPC 桥接与依赖注入", + "feature3": "日志、本地设置与更新器模块" + }, + "empty-app": { + "description": "在 apps/ 下创建空项目,稍后自行配置技术栈。", + "stack": "技术栈由你选择", + "feature1": "仅包含用于 Git 跟踪的 .gitkeep 文件", + "feature2": "自动登记到工作区", + "feature3": "添加代码后自行配置开发命令" + }, + "empty-service": { + "description": "在 services/ 下创建空项目,稍后自行配置技术栈。", + "stack": "技术栈由你选择", + "feature1": "仅包含用于 Git 跟踪的 .gitkeep 文件", + "feature2": "自动登记到工作区", + "feature3": "添加代码后自行配置开发命令" + }, + "empty-library": { + "description": "在 packages/ 下创建空项目,稍后自行配置技术栈。", + "stack": "技术栈由你选择", + "feature1": "仅包含用于 Git 跟踪的 .gitkeep 文件", + "feature2": "自动登记到工作区", + "feature3": "添加代码后自行配置开发命令" + } + }, + "noToolchain": "未配置" + }, + "services": { + "title": "开发服务", + "description": "在这里启动项目,查看输出并打开页面进行测试。", + "start": "启动服务", + "stop": "停止", + "restart": "重启", + "open": "打开访问", + "console": "控制台", + "live": "已连接", + "reconnecting": "正在连接…", + "search": "搜索日志", + "pause": "暂停滚动", + "follow": "跟随日志", + "copy": "复制", + "copied": "日志已复制", + "copyFailed": "无法复制,请在控制台选择文本后复制。", + "actionFailed": "操作失败,请重试。", + "saveFirst": "有未保存的配置,请先保存或丢弃,再启动或重启服务。", + "noDev": "尚未配置 dev 任务。请在概览中设置开发命令,或在项目的 mise.toml 中添加 dev 任务。", + "environmentMismatch": "当前服务使用 {{environment}} 环境。重启后将使用所选的 {{next}} 环境。", + "exitCode": "退出码:{{code}}", + "reachable": "本机端口可连接", + "unreachable": "本机端口尚不可连接", + "waitingURL": "等待服务输出本机访问地址。没有页面的服务也可正常运行。", + "urlHint": "启动后自动识别本机地址,也可在概览中设置访问地址。", + "waitingLogs": "正在准备运行环境,等待输出…", + "emptyLogs": "启动服务后,运行日志会显示在这里。", + "noMatches": "没有匹配的日志。", + "consoleHint": "日志已脱敏,最多保留最近 256 KiB。关闭页面不影响服务;关闭 one serve 会停止服务。", + "truncated": "较早的日志已移除,仅显示最近的输出。", + "status": { + "preparing": "准备中", + "running": "运行中", + "stopping": "停止中", + "stopped": "已停止", + "exited": "已退出", + "failed": "运行失败" + } } } diff --git a/apps/dashboard/src/pages/Overview.test.tsx b/apps/dashboard/src/pages/Overview.test.tsx index da3115d..84e3b71 100644 --- a/apps/dashboard/src/pages/Overview.test.tsx +++ b/apps/dashboard/src/pages/Overview.test.tsx @@ -19,22 +19,16 @@ import { import i18n from "@/lib/i18n"; import { Overview } from "@/pages/Overview"; import type { - BackendDomain, BackendSpec, Overview as OverviewPayload, ProjectSettingsResponse, } from "@/types/api"; -const server = setupServer(); +const server = setupServer( + http.get(/\/api\/workspaces?(?:\/[^/]+)?\/services$/, () => HttpResponse.json({ services: [] })), +); const catalogBackends: BackendSpec[] = [ - { - id: "env/dotenv", - domain: "env", - name: "dotenv", - capabilities: ["env-load"], - project: { configurable: false }, - }, { id: "env/infisical", domain: "env", @@ -54,7 +48,7 @@ const overview: OverviewPayload = { manifestVersion: 1, defaultEnvironment: "dev", environments: ["dev", "preview", "prod"], - domains: { env: "dotenv" }, + domains: { env: "infisical" }, }, projects: [ { @@ -63,7 +57,7 @@ const overview: OverviewPayload = { kind: "app", templateId: "react-spa", toolchain: "node", - domains: { env: "dotenv" }, + domains: { env: "infisical" }, }, { name: "api", @@ -71,7 +65,7 @@ const overview: OverviewPayload = { kind: "service", templateId: "go-api", toolchain: "go", - domains: { env: "dotenv" }, + domains: { env: "infisical" }, }, { name: "shared", @@ -79,7 +73,7 @@ const overview: OverviewPayload = { kind: "package", templateId: "typescript-package", toolchain: "node", - domains: { env: "dotenv" }, + domains: { env: "infisical" }, }, ], }; @@ -164,15 +158,6 @@ function renderOverview( ); } -async function chooseSelect( - user: ReturnType, - trigger: HTMLElement, - optionName: string, -) { - await user.click(trigger); - await user.click(await screen.findByRole("option", { name: optionName })); -} - async function openProjectSettings() { return screen.findByRole("region", { name: "Project settings" }); } @@ -197,19 +182,6 @@ async function openWorkspaceEnvironmentSettings(user: ReturnType [name, {}])), - }, - }; -} - describe("workspace overview Profile-only configuration", () => { beforeAll(async () => { server.listen({ onUnhandledRequest: "error" }); @@ -226,7 +198,7 @@ describe("workspace overview Profile-only configuration", () => { root: "/workspace/demo", environment: new URL(request.url).searchParams.get("env") ?? "", domain: "env", - backend: "dotenv", + backend: "infisical", configurable: false, }), ), @@ -236,7 +208,7 @@ describe("workspace overview Profile-only configuration", () => { root: "/workspace/demo", environment: new URL(request.url).searchParams.get("env") ?? "", domain: "env", - backend: "dotenv", + backend: "infisical", configurable: false, }), ), @@ -286,6 +258,53 @@ describe("workspace overview Profile-only configuration", () => { }); afterAll(() => server.close()); + it("creates a project from the empty workspace and selects it after refreshing", async () => { + const user = userEvent.setup(); + let created = false; + server.use( + http.get("http://localhost/api/project-templates", () => + HttpResponse.json({ + templates: [ + { + id: "react-spa", + name: "React", + description: "React", + category: "frontend", + directory: "apps", + toolchain: "node", + }, + ], + }), + ), + http.post("http://localhost/api/workspaces/demo-entry/projects", async ({ request }) => { + expect(await request.json()).toEqual({ name: "web", templateId: "react-spa" }); + created = true; + return HttpResponse.json( + { name: "web", relativeDir: "apps/web", templateId: "react-spa" }, + { status: 201 }, + ); + }), + http.get("http://localhost/api/workspaces/demo-entry/overview", () => + HttpResponse.json({ ...overview, projects: created ? [overview.projects![0]] : [] }), + ), + http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces: [] })), + ); + renderOverview({ ...overview, projects: [] }, "demo-entry", false, true); + const navigation = screen.getByRole("navigation", { name: "Workspace projects" }); + await user.click(within(navigation).getByRole("button", { name: "New project" })); + const dialog = await screen.findByRole("dialog", { name: "New project" }); + await user.type(within(dialog).getByLabelText("Project name"), "web"); + await user.click(within(dialog).getByRole("button", { name: "Create project" })); + const project = await within(navigation).findByRole("button", { name: "web apps/web" }); + expect(project.getAttribute("aria-current")).toBe("page"); + expect(screen.queryByRole("dialog", { name: "New project" })).toBeNull(); + }); + + it("hides project creation for an identity-conflicted workspace", () => { + renderOverview({ ...overview, projects: [] }, "demo-entry", true); + expect(screen.queryByRole("button", { name: "New project" })).toBeNull(); + }); + it("shows projects in a persistent left-hand list with project settings tabs", async () => { renderOverview(); @@ -342,56 +361,13 @@ describe("workspace overview Profile-only configuration", () => { ); }); - it("stages a Workspace env backend change for Manifest review", async () => { - let backendWrites = 0; - const configurableOverview: OverviewPayload = { - ...overview, - workspace: { - ...overview.workspace!, - domains: { ...overview.workspace?.domains, env: "infisical" }, - }, - }; - server.use( - http.get("http://localhost/api/workspaces/demo-entry/environment", () => - HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: "dev", - revision: "sha256:test-revision", - domain: "env", - backend: "infisical", - configurable: true, - }), - ), - http.get("http://localhost/api/configure/env/infisical", () => - HttpResponse.json(sectionResponse("env", "infisical", ["work"])), - ), - http.put("http://localhost/api/workspaces/demo-entry/manifest", () => { - backendWrites += 1; - return HttpResponse.json({ - schema: "one-cli/workspace-manifest-apply/v1", - revision: "sha256:next", - applied: 1, - }); - }), - ); + it("shows Infisical without a backend switch", async () => { const user = userEvent.setup(); - renderOverview(configurableOverview, "demo-entry"); - + renderOverview(overview, "demo-entry"); const region = await openWorkspaceEnvironmentSettings(user); - await chooseSelect(user, within(region).getByRole("combobox", { name: "Backend" }), "dotenv"); - - expect(useManifestDraftStore.getState().drafts[manifestDraftKey("demo-entry")]).toMatchObject({ - revision: "sha256:test-revision", - workspace: { environment: { backend: "dotenv" } }, - }); - expect(within(region).getByText("Pending review")).toBeDefined(); - expect(backendWrites).toBe(0); - expect( - within(screen.getByRole("dialog", { name: "Workspace settings" })).getByRole("button", { - name: "Save changes · 1", - }), - ).toBeDefined(); + expect(within(region).getByText("Infisical")).toBeDefined(); + expect(within(region).queryByRole("combobox", { name: "Backend" })).toBeNull(); + expect(useManifestDraftStore.getState().drafts[manifestDraftKey("demo-entry")]).toBeUndefined(); }); it("keeps identity fields read-only and stages editable General manifest fields", async () => { @@ -424,7 +400,7 @@ describe("workspace overview Profile-only configuration", () => { await user.type(within(inspector).getByLabelText("Build version"), "2.0.0"); expect( useManifestDraftStore.getState().drafts[manifestDraftKey()]?.changes.web?.general, - ).toEqual({ buildVersion: "2.0.0", devCommand: "pnpm dev" }); + ).toEqual({ buildVersion: "2.0.0", devCommand: "pnpm dev", devURL: "" }); expect(within(inspector).queryByRole("button", { name: "Save local binding" })).toBeNull(); expect(receivedEnvironment).toBe("dev"); }); @@ -461,6 +437,38 @@ describe("workspace overview Profile-only configuration", () => { }, ); + it("shows configured task dependencies and cache settings without claiming cache hits", async () => { + server.use( + http.get("http://localhost/api/workspace/projects/web", () => + HttpResponse.json({ + ...webSettings, + project: { + ...webSettings.project, + tasks: { + status: "ready", + entries: [ + { + name: "//apps/web:build", + source: "apps/web/mise.toml", + depends: ["//packages/lib:build"], + outputs: ["dist"], + cacheEnabled: true, + }, + ], + }, + }, + }), + ), + ); + renderOverview(); + const inspector = await openProjectSettings(); + const tasks = await within(inspector).findByRole("region", { name: "Configured tasks" }); + expect(within(tasks).getByText("//apps/web:build")).toBeDefined(); + expect(within(tasks).getByText("Dependencies: //packages/lib:build")).toBeDefined(); + expect(within(tasks).getByText("Artifact caching is configured.")).toBeDefined(); + expect(within(tasks).queryByRole("button")).toBeNull(); + }); + it("keeps project environment configuration separate from remote secret operations", async () => { const user = userEvent.setup(); renderOverview(); diff --git a/apps/dashboard/src/pages/ProjectTemplates.test.tsx b/apps/dashboard/src/pages/ProjectTemplates.test.tsx new file mode 100644 index 0000000..2a46ae8 --- /dev/null +++ b/apps/dashboard/src/pages/ProjectTemplates.test.tsx @@ -0,0 +1,155 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { MemoryRouter } from "react-router-dom"; +import { SWRConfig } from "swr"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { App } from "@/App"; +import { ProjectTemplates } from "@/pages/ProjectTemplates"; +import i18n from "@/lib/i18n"; + +const registry = JSON.parse( + readFileSync(resolve(process.cwd(), "../../packages/templates/registry.json"), "utf8"), +); +const templates = registry.templates.map( + (template: { + id: string; + name: string; + description: string; + category: "frontend" | "backend" | "library"; + toolchain: string; + }) => ({ + ...template, + directory: { frontend: "apps", backend: "services", library: "packages" }[template.category], + }), +); +const server = setupServer( + http.get("http://localhost/api/project-templates", () => HttpResponse.json({ templates })), + http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces: [] })), + http.get("http://localhost/api/session", () => + HttpResponse.json({ session: { loggedIn: false } }), + ), +); +function show(withApp = false) { + return render( + new Map(), shouldRetryOnError: false }}> + + {withApp ? : } + + , + ); +} +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeEach(async () => { + await i18n.changeLanguage("en-US"); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("project template catalog", () => { + it.each(["en-US", "zh-CN"])("introduces every registered template in %s", async (locale) => { + await i18n.changeLanguage(locale); + show(); + expect(await screen.findAllByRole("article")).toHaveLength(templates.length); + for (const template of templates) { + const name = i18n.t(`projectCreate.templates.${template.id}.name`); + const card = screen.getByRole("article", { name }); + for (const key of ["description", "stack", "feature1", "feature2", "feature3"]) { + const value = i18n.getResource( + locale, + "translation", + `templateCatalog.templates.${template.id}.${key}`, + ); + expect(typeof value).toBe("string"); + expect(value.length).toBeGreaterThan(0); + expect(within(card).getByText(value)).toBeDefined(); + } + expect(within(card).getByText(`${template.directory}/`)).toBeDefined(); + } + expect( + screen + .getByRole("link", { name: i18n.t("templateCatalog.chooseWorkspace") }) + .getAttribute("href"), + ).toBe("/?env=preview"); + }); + + it("combines category and feature search, and resets an empty result", async () => { + const user = userEvent.setup(); + show(); + await screen.findAllByRole("article"); + await user.click(screen.getByRole("button", { name: /Services/ })); + expect(screen.getAllByRole("article")).toHaveLength( + templates.filter((template: { category: string }) => template.category === "backend").length, + ); + await user.type( + screen.getByRole("textbox", { name: "Search templates, stacks, or features…" }), + " DRIZZLE ", + ); + expect(screen.getAllByRole("article")).toHaveLength(1); + expect(screen.getByRole("article", { name: "NestJS API service" })).toBeDefined(); + await user.click(screen.getByRole("button", { name: /Libraries/ })); + expect(screen.queryAllByRole("article")).toHaveLength(0); + expect(screen.getByRole("heading", { name: "No matching templates" })).toBeDefined(); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + expect(screen.getAllByRole("article")).toHaveLength(templates.length); + }); + + it("updates introductions when the language changes", async () => { + show(); + await screen.findByRole("article", { name: "React single-page application" }); + await act(async () => { + await i18n.changeLanguage("zh-CN"); + }); + expect(await screen.findByRole("article", { name: "React 单页应用" })).toBeDefined(); + expect(screen.queryByText("Client-side routing and error boundaries")).toBeNull(); + expect(screen.getByText("客户端路由与错误边界")).toBeDefined(); + }); + + it("recovers from a catalog error", async () => { + server.use( + http.get( + "http://localhost/api/project-templates", + () => new HttpResponse(null, { status: 500 }), + ), + ); + const user = userEvent.setup(); + show(); + expect(await screen.findByRole("alert")).toBeDefined(); + server.use( + http.get("http://localhost/api/project-templates", () => HttpResponse.json({ templates })), + ); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findAllByRole("article")).toHaveLength(templates.length); + }); + + it("shows the catalog empty state", async () => { + server.use( + http.get("http://localhost/api/project-templates", () => + HttpResponse.json({ templates: [] }), + ), + ); + show(); + expect( + await screen.findByRole("heading", { name: i18n.t("projectCreate.noTemplates") }), + ).toBeDefined(); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("exposes the page through the brand menu and breadcrumb", async () => { + const user = userEvent.setup(); + show(true); + await screen.findByRole("heading", { name: "Project templates", level: 1 }); + const breadcrumb = screen.getByRole("navigation", { name: "breadcrumb" }); + expect(within(breadcrumb).getByText("Project templates")).toBeDefined(); + await user.click(screen.getByRole("button", { name: "One CLI navigation menu" })); + const item = screen.getByRole("menuitem", { name: "Project templates" }); + expect(item.getAttribute("href")).toBe("/templates?env=preview"); + expect(item.getAttribute("aria-current")).toBe("page"); + await user.click(item); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(screen.getByRole("heading", { name: "Project templates", level: 1 })).toBeDefined(); + }); +}); diff --git a/apps/dashboard/src/pages/ProjectTemplates.tsx b/apps/dashboard/src/pages/ProjectTemplates.tsx new file mode 100644 index 0000000..f70f68b --- /dev/null +++ b/apps/dashboard/src/pages/ProjectTemplates.tsx @@ -0,0 +1,239 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import { + ArrowRight, + BookOpen, + Boxes, + Braces, + Check, + Folder, + Globe, + Monitor, + Package, + Search, + Server, + Smartphone, + type LucideIcon, +} from "lucide-react"; +import { getProjectTemplates, projectTemplatesKey } from "@/api/project-creation"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ErrorNotice, PageHeader, SearchInput, StatePanel } from "@/components/ui/page-layout"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; + +const CATEGORIES = ["all", "frontend", "backend", "library"] as const; +const TEMPLATE_ICONS: Record = { + "nestjs-api": Server, + "go-api": Server, + "astro-site": Globe, + "starlight-docs": BookOpen, + "nextjs-app": Globe, + "react-spa": Braces, + "expo-mobile": Smartphone, + "electron-app": Monitor, + "ts-library": Package, + "go-lib": Package, +}; + +export function ProjectTemplates() { + const { t } = useTranslation(); + const catalog = useSWR(projectTemplatesKey, getProjectTemplates); + const [query, setQuery] = useState(""); + const [category, setCategory] = useState<(typeof CATEGORIES)[number]>("all"); + const toolchainLabels = { node: "Node.js", go: "Go", none: t("templateCatalog.noToolchain") }; + const templates = (catalog.data?.templates ?? []).map((template) => ({ + ...template, + name: t(`projectCreate.templates.${template.id}.name`, { defaultValue: template.name }), + description: t(`templateCatalog.templates.${template.id}.description`, { + defaultValue: template.description, + }), + stack: t(`templateCatalog.templates.${template.id}.stack`, { + defaultValue: toolchainLabels[template.toolchain], + }), + features: ["feature1", "feature2", "feature3"] + .map((key) => t(`templateCatalog.templates.${template.id}.${key}`, { defaultValue: "" })) + .filter(Boolean), + })); + const search = query.trim().toLocaleLowerCase(); + const filtered = templates.filter( + (template) => + (category === "all" || template.category === category) && + [ + template.name, + template.id, + template.description, + template.stack, + template.directory, + toolchainLabels[template.toolchain], + ...template.features, + ] + .join(" ") + .toLocaleLowerCase() + .includes(search), + ); + function resetFilters() { + setCategory("all"); + setQuery(""); + } + + return ( +
+ +
+
+
+ +
+ {catalog.isLoading ? ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ ) : catalog.error ? ( + void catalog.mutate().catch(() => undefined)}> + {t("workspaces.retry")} + + } + > + {t("projectCreate.templatesFailed")} + + ) : templates.length === 0 ? ( + + ) : ( +
+
+
+ {CATEGORIES.map((value) => ( + + ))} +
+
+ setQuery(event.target.value)} + placeholder={t("templateCatalog.search")} + aria-label={t("templateCatalog.search")} + /> +
+
+

+ {t("templateCatalog.resultCount", { count: filtered.length, total: templates.length })} +

+ {filtered.length === 0 ? ( + + + + ) : ( +
+ {filtered.map((template) => { + const Icon = TEMPLATE_ICONS[template.id] ?? Boxes; + return ( +
+
+ + +
+

+ {template.name} +

+

+ {template.id} +

+
+
+
+ + {t(`projectCreate.categories.${template.category}`)} + +
+

+ {template.description} +

+

{template.stack}

+ {template.features.length > 0 && ( +
    + {template.features.map((feature) => ( +
  • +
  • + ))} +
+ )} +
+ + + {toolchainLabels[template.toolchain]} +
+
+ ); + })} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/pages/WorkspaceHome.test.tsx b/apps/dashboard/src/pages/WorkspaceHome.test.tsx index 09ec922..532dc17 100644 --- a/apps/dashboard/src/pages/WorkspaceHome.test.tsx +++ b/apps/dashboard/src/pages/WorkspaceHome.test.tsx @@ -183,13 +183,17 @@ describe("Workspace discovery and recovery", () => { ); } + // This multi-step jsdom interaction shares CI CPUs with the cold Go build. it("combines path search with attention filtering and restores the list on clear", async () => { serveRegistry(); const user = userEvent.setup(); renderHome("/?env=preview"); await screen.findByRole("link", { name: /Alpha/ }); const search = screen.getByRole("textbox", { name: "Search name, path or ID…" }); - await user.type(search, " /WORKSPACES/ALPHA "); + // The case/whitespace filter assertion only needs the final query. Pasting + // avoids a full workspace-card render for every character on CI runners. + await user.click(search); + await user.paste(" /WORKSPACES/ALPHA "); expect(screen.getAllByRole("article")).toHaveLength(1); expect(screen.getByRole("link", { name: /Alpha/ }).getAttribute("href")).toBe( "/workspace/alpha-entry?env=preview", @@ -202,7 +206,7 @@ describe("Workspace discovery and recovery", () => { await user.click(screen.getByRole("button", { name: /Needs attention/ })); expect(screen.getAllByRole("article")).toHaveLength(4); expect(screen.queryByRole("link", { name: /Alpha/ })).toBeNull(); - }); + }, 10_000); it("keeps existing workspaces during a failed refresh and supports retry", async () => { serveRegistry(); diff --git a/apps/dashboard/src/router/SettingsDialog.tsx b/apps/dashboard/src/router/SettingsDialog.tsx deleted file mode 100644 index 86f0729..0000000 --- a/apps/dashboard/src/router/SettingsDialog.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { AccountSettings } from "@/features/infisical-session/AccountSettings"; -export function SettingsDialog() { - const { t } = useTranslation(); - return ( - - - - - - - {t("sidebar.settings")} - {t("session.description")} - - - - - ); -} diff --git a/apps/dashboard/src/router/routes.test.tsx b/apps/dashboard/src/router/routes.test.tsx index 3133299..68d89ee 100644 --- a/apps/dashboard/src/router/routes.test.tsx +++ b/apps/dashboard/src/router/routes.test.tsx @@ -18,6 +18,7 @@ import type { } from "@/types/api"; const server = setupServer( + http.get(/\/api\/workspaces?(?:\/[^/]+)?\/services$/, () => HttpResponse.json({ services: [] })), http.get("http://localhost/api/session", () => HttpResponse.json({ session: { loggedIn: false, expired: false } }), ), @@ -72,7 +73,7 @@ function workspaceOverview(workspace: WorkspaceRegistryEntry): Overview { manifestVersion: 1, environments: ["dev"], defaultEnvironment: "dev", - domains: { env: "dotenv" }, + domains: { env: "infisical" }, }, projects: [ { @@ -81,7 +82,7 @@ function workspaceOverview(workspace: WorkspaceRegistryEntry): Overview { kind: "app", templateId: "react-spa", toolchain: "node", - domains: { env: "dotenv" }, + domains: { env: "infisical" }, }, ], }; @@ -124,7 +125,7 @@ function registerCatalogHandler() { root: entry.root, environment: new URL(request.url).searchParams.get("env") ?? "dev", domain: "env", - backend: "dotenv", + backend: "infisical", configurable: false, }); }), @@ -149,7 +150,7 @@ function registerCatalogHandler() { devCommand: "pnpm dev", availableEnvironments: ["dev", "preview", "prod"], environment: { - backend: "dotenv", + backend: "infisical", path: ".env", inherits: true, disabled: false, @@ -330,7 +331,8 @@ describe("multi-workspace routing", () => { await user.click(within(inspector).getByRole("button", { name: "Workspace settings" })); const dialog = await screen.findByRole("dialog", { name: "Workspace settings" }); expect( - (within(dialog).getByRole("combobox", { name: "Backend" }) as HTMLButtonElement).disabled, + (within(dialog).getByRole("combobox", { name: "Storage project" }) as HTMLButtonElement) + .disabled, ).toBe(true); }); @@ -385,8 +387,8 @@ describe("multi-workspace routing", () => { , ); - const navigation = await screen.findAllByRole("link", { name: "Shared credentials" }); - await user.click(navigation[0]); + await user.click(screen.getByRole("button", { name: "One CLI navigation menu" })); + await user.click(await screen.findByRole("menuitem", { name: "Shared credentials" })); await screen.findByRole("heading", { name: "Shared credentials" }); await user.click(screen.getByRole("link", { name: "Sign in with browser" })); await screen.findByRole("heading", { name: "Infisical" }); diff --git a/apps/dashboard/src/router/routes.tsx b/apps/dashboard/src/router/routes.tsx index 1fca401..2ca42a7 100644 --- a/apps/dashboard/src/router/routes.tsx +++ b/apps/dashboard/src/router/routes.tsx @@ -18,6 +18,7 @@ import { Overview } from "@/pages/Overview"; import { AccountSettings } from "@/features/infisical-session/AccountSettings"; import { GlobalVariables } from "@/features/global-variables/GlobalVariables"; +import { ProjectTemplates } from "@/pages/ProjectTemplates"; import { WorkspaceHome } from "@/pages/WorkspaceHome"; import type { WorkspaceRegistryEntry } from "@/types/api"; @@ -250,6 +251,7 @@ const UnknownWorkspace: React.FC = () => { const routes: RouteObject[] = [ { path: "/", element: }, + { path: "/templates", element: }, { path: "/workspace/:entryId", element: }, { path: "/settings", element: }, { path: "/global", element: }, diff --git a/apps/dashboard/src/types/api.ts b/apps/dashboard/src/types/api.ts index 197344a..2c27854 100644 --- a/apps/dashboard/src/types/api.ts +++ b/apps/dashboard/src/types/api.ts @@ -169,6 +169,7 @@ export interface ProjectSettings { packageManager?: string; buildVersion?: string; devCommand?: string; + devURL?: string; build?: { command?: string; source?: string; @@ -177,6 +178,16 @@ export interface ProjectSettings { defaultEnvironment?: string; availableEnvironments?: string[]; environment: ProjectEnvironmentSettings; + tasks?: { + status: "ready" | "unavailable"; + entries: Array<{ + name: string; + source: string; + depends: string[] | null; + outputs: string[] | null; + cacheEnabled: boolean; + }>; + }; } export interface ProjectSettingsResponse { @@ -192,6 +203,7 @@ export interface ProjectSettingsResponse { export interface ProjectGeneralPatch { buildVersion: string; devCommand: string; + devURL?: string; } export interface ProjectEnvironmentPatch { @@ -275,3 +287,18 @@ export interface WorkspaceEnvironmentSettings { projectName: string; siteUrl: string; } + +export type ServiceStatus = "preparing" | "running" | "stopping" | "stopped" | "exited" | "failed"; +export interface DevService { + id: string; + project: string; + environment: string; + status: ServiceStatus; + startedAt: string; + finishedAt?: string; + exitCode?: number; + endpoints: Array<{ url: string; reachable: boolean }>; + logs: Array<{ seq: number; text: string }>; + firstSeq: number; + nextSeq: number; +} diff --git a/apps/docs/content/blog/en/agent-skill-context.md b/apps/docs/content/blog/en/agent-skill-context.md index c46f27a..d462450 100644 --- a/apps/docs/content/blog/en/agent-skill-context.md +++ b/apps/docs/content/blog/en/agent-skill-context.md @@ -6,7 +6,7 @@ author: "One CLI Team" tags: ["skill", "codex", "dependencies"] --- -> Historical design note: the detailed playbooks described below have been replaced by a minimal `one-cli` skill. See [Skills](/en/docs/skills/) for the current `one skills install` workflow. +> Historical design note: the bundled skill feature has been removed. Current task usage is documented in [one run](/en/docs/run/). ## A skill is not marketing copy diff --git a/apps/docs/content/blog/zh/agent-skill-context.md b/apps/docs/content/blog/zh/agent-skill-context.md index 610a3af..8d8cd28 100644 --- a/apps/docs/content/blog/zh/agent-skill-context.md +++ b/apps/docs/content/blog/zh/agent-skill-context.md @@ -6,7 +6,7 @@ author: "One CLI Team" tags: ["skill", "codex", "dependencies"] --- -> 历史设计记录:下文的详细 playbook 已由精简的 `one-cli` skill 替代。当前 `one skills install` 用法见 [Skills](/zh/docs/skills/)。 +> 历史设计记录:内置 skill 功能已移除。当前任务用法见 [one run](/zh/docs/run/)。 ## skill 不是营销文档 diff --git a/apps/docs/content/docs/en/add.md b/apps/docs/content/docs/en/add.md index 1cced8e..301650d 100644 --- a/apps/docs/content/docs/en/add.md +++ b/apps/docs/content/docs/en/add.md @@ -3,7 +3,9 @@ title: one add description: Add a templated project to an existing workspace. --- -`one add` selects a technology stack, writes a locally developable project into the workspace, and registers it in the manifest. CI and deployment remain unconfigured by default. +`one add` creates a project from a template or an empty directory starter and registers it in the workspace manifest. CI and deployment remain unconfigured by default. + +When hk is enabled, `one add` updates the default language checks in `.config/hk.pkl`, preserving user edits and comments. Workspace and project tasks share the root `mise.toml`; project directories retain only their native command files. See [hooks](/en/docs/hk/) and [tasks](/en/docs/run/) for configuration details. There are two entry points: @@ -12,6 +14,8 @@ There are two entry points: `template-id` is the template ID, such as `nestjs-api`, `nextjs-app`, or `ts-library`. It is not the project name; the project name comes from `--name`. +Creation and project addition prepare mise and automatically trust complete `mise.toml` files generated by One, so entering the new workspace does not require a separate trust command. Existing custom configuration keeps mise's trust policy. If no compatible runtime is installed, One may download its managed mise binary; project tools and dependencies are still installed when needed. A trust failure preserves the generated files and prints a recovery command. + ## Usage ```bash @@ -43,6 +47,20 @@ Non-interactive calls should pass both template ID and project name: one add nestjs-api --name api --yes ``` +## Create an Empty Project + +In an existing workspace, create and register a project before choosing a language or framework: + +```bash +one add empty-app --name web --yes +one add empty-service --name api --yes +one add empty-library --name shared --yes +``` + +These templates create `apps/web/`, `services/api/`, and `packages/shared/`, respectively, containing only a `.gitkeep` file so Git tracks the directory. They register `toolchain: "none"` and generate no `package.json`, `go.mod`, dependencies, or startup tasks. Interactive `one add` and the Dashboard's new-project picker also offer these choices. + +For Node or Go code, set the project's `toolchain` in `one.manifest.json` to `node` or `go`. Node projects use `packageManager: "pnpm"` and need membership in the root package workspace; Go modules need membership in the root `go.work`. Define tasks in `package.json` / `Taskfile.yml`, or set the project's `dev.command`, then run `one init mise` to update task configuration. For other languages, keep `toolchain: "none"` and define your own tools and tasks in the root `mise.toml`, or set `dev.command`. Configure the commands before using `one dev` / `one build`. + ## Output ```json @@ -128,6 +146,5 @@ Not sure which one to use? Read the [template decision tree](/en/docs/templates/ - Check `one.manifest.json#projects[]` to confirm registration - Agent docs and local-development configuration are synced by `one add` -- Run `one dev ` for development and `one build ` to build -- Optionally run `one ci enable ` to generate its GitHub Actions workflow +- Run `one dev -p ` for development and `one build -p ` to build - `one add` does not install dependencies: JS / TS workspaces install from the root with the package manager; Go projects run `go mod download` in the project directory, then `go mod tidy` only after changing imports or when module metadata needs repair diff --git a/apps/docs/content/docs/en/ai-native.md b/apps/docs/content/docs/en/ai-native.md index 29e04ca..4defc1a 100644 --- a/apps/docs/content/docs/en/ai-native.md +++ b/apps/docs/content/docs/en/ai-native.md @@ -97,9 +97,7 @@ The full catalogue is in [Error codes](/en/docs/error-codes/). ## Rule 3: Project Instructions Belong To The Team -New workspaces include a short root `AGENTS.md` requiring the `one-cli` skill. If it is missing, agents are directed to run `one skills install`. Ordinary and preset creation use the same instructions; subsequent `one add` calls preserve team edits. -The skill follows [One Workspace Convention](https://github.com/1cli-team/one-workspace-convention) and directs agents to the installed CLI help. Agents can also read `one.manifest.json` and project README files. Skill installation is explicit and independent of workspace creation; see [Skills](./skills). ## Rule 4: Configuration And Credentials Have Boundaries diff --git a/apps/docs/content/docs/en/build.md b/apps/docs/content/docs/en/build.md index d56bd18..8fea955 100644 --- a/apps/docs/content/docs/en/build.md +++ b/apps/docs/content/docs/en/build.md @@ -1,43 +1,19 @@ --- -title: one build -description: Build all projects or one selected project. +title: build task +description: Build selected projects and their local dependencies through mise. --- -`one build` prepares tools and application dependencies, then runs project build tasks to completion. +`one build` is shorthand for `one run build`. `build` is an ordinary task name, just like `test` or `lint`, and uses the same task parsing, flags, and help. The effective task graph comes from mise, including your overrides in `mise.toml`. -```bash +```sh one build -one build web -one build apps/web +one build -p web -p api one build -p web --env prod -one build --dry-run -o json +one build -p web --dry-run -o json +one build --cache off --force +one build -p web --ui raw ``` -Without a selector, builds all buildable manifest projects, even when invoked from a project directory. A project name or workspace-relative path selects only that project; its local dependencies are not automatically built. +Node commands come from `package.json` and use pnpm. Go commands come from Taskfile. Full builds include available build tasks; explicitly selecting a project without one fails. Local Node dependencies build before their consumers. Default concurrency is one; `--concurrency 4` allows independent tasks to run in parallel. -## Build commands - -The Dashboard project overview shows the resolved build command and its source. This field is read-only; edit the project's `package.json` or `Taskfile.yml`, then refresh the page to see the update. - -- Node: runs `build` from the current `package.json` using the workspace package manager (`pnpm`, `npm`, `yarn`, or `bun`). -- Go: runs `task build` from the project's `Taskfile.yml`. The Go API template writes `bin/server`; the Go library template compiles packages with `go build ./...`. Older libraries can add that task to their Taskfile. - -Go projects require a Taskfile. Full workspace builds skip projects without a build task and report `no-build-task`; explicitly selecting one fails with `RUNTIME_TASK_NOT_FOUND`. Invalid configuration and missing required files fail before preparation. A workspace with no build tasks also fails. Build scripts control artifact locations. - -## Ordering and execution - -Full builds run sequentially, with local Node dependencies before their consumers. Dependencies, devDependencies, and optionalDependencies are matched by package name, or by directory for `file:` / `link:` dependencies. Independent projects retain manifest traversal order. Duplicate package names and dependency cycles are reported before execution. Go resolves its package dependencies through the Go toolchain. - -The first failure stops the build. Remaining tasks are reported as `not_run`, and the failing child's exit code is preserved. Ctrl+C stops the active process tree. Each project's logs carry its name. - -## Tools, dependencies, and environments - -Uses the same automatic mise/builtin selection and dependency preparation as `one dev`, including libraries without dev commands. Node dependencies are installed once at the workspace root. Each build runs through the `one run` environment-loading and PATH rules, in its project directory. `--env` selects an environment; otherwise the manifest default applies. - -`--dry-run` reads configuration and reports ordered commands, directories, and skipped projects. It does not install tools or dependencies, load secrets, access the network, or write files. - -## Output - -`-o json` and `-o yaml` return `one-cli/build-plan/v1` for previews and `one-cli/build-result/v1` for execution results. Process logs go to stderr, leaving stdout parseable. Results include per-project status, command, duration, and exit code. Preparation failures include an error and leave build tasks `not_run`. - -Use [`one run`](/docs/run/) for custom commands. +The Dashboard displays the project build command and its source. Edit the source file to change it. Workspace dependencies and cache settings live in mise configuration. See [one run](/en/docs/run/) for task management, custom caching, terminal modes, and Actions examples. diff --git a/apps/docs/content/docs/en/ci.md b/apps/docs/content/docs/en/ci.md deleted file mode 100644 index aafa2c0..0000000 --- a/apps/docs/content/docs/en/ci.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: Continuous integration -description: Inspect, enable, refresh, or remove optional CI workflows. ---- - -Continuous integration is opt-in. `one create` and `one add` do not generate -workflow files. The current build supports GitHub Actions. - -## Usage - -```bash -one ci -one ci enable [project] -one ci sync [project] -one ci disable [project] -``` - -`one ci` is read-only and shows each project's current state and the most useful -next command. - -## Commands - -| Command | Behavior | -|---|---| -| `one ci` | Show CI status for all projects | -| `one ci enable web` | Generate the canonical workflow for `web` | -| `one ci enable` | Enable CI for every project | -| `one ci sync web` | Regenerate `web` when CI is already enabled | -| `one ci sync` | Regenerate every workflow that already exists | -| `one ci disable web` | Remove `web`'s generated workflow after confirmation | -| `one ci disable --yes` | Remove all generated workflows without prompting | - -Refusing the disable confirmation or pressing Ctrl-C is a normal cancellation: -the command exits successfully and does not modify files. - -Non-interactive calls never treat the absence of a terminal as consent. Pass -`--yes` explicitly or the command returns `CI_DISABLE_CONFIRMATION_REQUIRED` -without removing any workflow. - -## Files and workspace state - -For GitHub Actions, One CLI writes one canonical file per project under -`.github/workflows/`. CI selection is not written to `one.manifest.json`; the -generated file is the state. `enable`, `sync`, and `disable` therefore leave the -manifest unchanged. - -## Automation - -```bash -one ci -o json -one ci enable web --provider ci/github-actions -o json -one ci sync --project web -o json -one ci disable web --yes -o json -``` - -Stable schemas are `one-cli/ci-status/v1`, `one-cli/ci-enable/v1`, -`one-cli/ci-sync/v1`, and `one-cli/ci-disable/v1`. The provider ID and JSON field -names remain stable and are not translated. `--project` is retained for older -scripts; everyday help prefers the positional project. - -## Errors - -| Code | Recovery | -|---|---| -| `CI_DISABLE_CONFIRMATION_REQUIRED` | Review the target projects, then rerun with `--yes` | -| `CI_NOT_ENABLED` | Run `one ci enable `, then retry sync | -| `CI_PROVIDER_UNKNOWN` | Use an ID from `error.context.available_providers` | -| `CI_RENDER_FAILED` | Inspect the project and workflow path in the error context | - -See [Error codes](/en/docs/error-codes/) for the stable error envelope. diff --git a/apps/docs/content/docs/en/cli-overview.md b/apps/docs/content/docs/en/cli-overview.md index d9706d1..9a79ce7 100644 --- a/apps/docs/content/docs/en/cli-overview.md +++ b/apps/docs/content/docs/en/cli-overview.md @@ -7,15 +7,17 @@ description: Daily commands and advanced entry points. | --- | --- | | `one create` | Create a workspace | | `one add` | Add a project | -| `one dev` | Start development | -| `one build` | Build projects | | `one env` | Manage project variables | | `one login` / `one whoami` / `one logout` | Single browser session | | `one env --global` | Discover global variable locations and environments | -| `one run` | Run a command with injected variables | +| `one exec` | Run a command with injected variables | | `one serve` | Open the Dashboard | | `one locale` | Local language preference | | `one init mise` / `one init hooks` | Workspace tool configuration | -| `one ci` / `one templates` / `one skills` | Automation and resources | +| `one run` | List and execute workspace tasks | +| `one templates` | List project templates | +| `one hk` | Run Git hook checks | + +`dev`, `build`, `test`, and `lint` are ordinary workspace task names. `one ` is shorthand for `one run `: `one dev` runs `one run dev`, and `one build` runs `one run build`. See [tasks](/en/docs/run/) for the shared flags and task discovery. Discover the full catalogue with `one help --all`, then read command-specific `--help`. Agents discover metadata and execution options through the CLI, without copying commands from the Dashboard. See [login and shared credentials](/en/docs/login/). diff --git a/apps/docs/content/docs/en/create.md b/apps/docs/content/docs/en/create.md index 57412ab..352b4a2 100644 --- a/apps/docs/content/docs/en/create.md +++ b/apps/docs/content/docs/en/create.md @@ -18,7 +18,6 @@ one create [dir] [options] | `dir` | Target directory. Use `.` to create in the current directory with `basename(cwd)` as the name. The target must not exist or must be empty | | `-n, --name ` | Workspace name. Defaults to `basename(dir)` | | `-y, --yes` | Non-interactive mode; uses defaults and requires an explicit `dir` | -| `--env-provider ` | Env backend selection. Defaults to `dotenv`; pass `infisical` explicitly when needed | | `-o, --output ` | `json` / `yaml` / `text`; default is TTY-aware auto detection | ## Interactive Mode @@ -28,13 +27,11 @@ Running `one create` with no arguments opens terminal questions for: 1. Target directory, such as `./my-app`; use `.` for the current directory. 2. Workspace name, optional; when empty, One CLI uses the target directory basename. -`one create` does not ask about deploy / container, and it no longer asks whether to switch to Infisical. The default env backend is `env/dotenv`; use `--env-provider infisical` to choose Infisical at create time. For scripts, CI, and agents, use non-interactive commands: ```bash one create my-app --yes -one create my-app --yes --env-provider infisical ``` ## Default Workspace Capabilities @@ -45,28 +42,20 @@ one create my-app --yes --env-provider infisical | Capability | Default | Behavior | |---|---|---| -| Environment variables | Local `.env` files | Switch to Infisical with `--env-provider infisical` or later with `one env switch infisical` | -| Local development | `one dev` | Runs project development commands through One CLI's built-in supervisor | +| Environment variables | Unbound | Bind Infisical when variables are needed; otherwise inherit the shell environment | +| Tool environment | mise | Creates root `mise.toml`; `one add` registers project tasks in that same file | +| Git checks | hk | Creates `.config/hk.pkl` and installs local Git launchers | +| Local development | `one dev` | Runs the development task graph through mise | + +Creation and project addition prepare mise and automatically trust complete `mise.toml` files generated by One, so entering the new workspace does not require a separate trust command. Existing custom configuration keeps mise's trust policy. If no compatible runtime is installed, One may download its managed mise binary; project tools and dependencies are still installed when needed. A trust failure preserves the generated files and prints a recovery command. Continuous integration is not configured automatically. Creating a workspace does not write files under `.github/workflows/`. After adding a project, enable -it explicitly with `one ci enable ` if needed. - -## `--env-provider` Semantics - -`--env-provider ` explicitly selects the env backend: +a workflow that calls `one run ci` when needed. -```bash -one create my-app -y --env-provider infisical -``` +## Infisical binding -Sign in to Infisical in your browser first: - -```bash -one login -``` - -`one create --env-provider infisical` tries to auto-bind or create an Infisical project. If login, network, or permission is not ready, workspace creation still succeeds; the first `one env set/get/list/pull` retries lazy auto-bind. +Creation does not contact Infisical or write an `env` binding. Sign in with `one login` when you need managed variables. The first `one env set`, `get`, or `list` initializes the binding; Dashboard also lets you select an existing project. Execution without a binding uses the shell environment. ## Output @@ -76,16 +65,13 @@ one login "project_name": "my-app", "created_path": "/abs/path/my-app", "created_in_place": false, - "package_manager": "pnpm", - "secrets_backend": "dotenv", - "ci_enabled": false, + "package_manager": "", + "secrets_backend": "infisical", "dev_enabled": true } ``` -`secrets_backend` is the stable environment-source ID (`dotenv` / `infisical`). -`ci_enabled` remains in the response for wire compatibility and is `false` by -default; `dev_enabled` is `true`. Deployment configuration is added later. +`secrets_backend` records the supported source (`infisical`), not whether a binding already exists. `dev_enabled` is `true`. Empty workspaces have no package manager yet. ## Examples @@ -106,7 +92,7 @@ one create my-app --yes ### Use Infisical As Secrets Backend ```bash -one create my-app --yes --env-provider infisical +one create my-app --yes ``` ### Create In Current Directory @@ -132,11 +118,11 @@ pnpm install | `EXISTING_TARGET_NOT_EMPTY` | Choose an empty directory, or delete the target manually and retry | | `INVALID_NAME` | Names must match `^[a-zA-Z0-9][a-zA-Z0-9_-]*$`; replace spaces with `-` | | `PROJECT_NAME_REQUIRED` | Pass the workspace directory as the positional argument in non-interactive mode | -| `BACKEND_ID_UNKNOWN` | Invalid `--env-provider`; legal values are `dotenv` / `infisical` | | `WORKSPACE_NESTED_FORBIDDEN` | Do not create a workspace inside an existing workspace; use another directory or `one add` | Full table: [Error codes](/en/docs/error-codes/). ## Agent instructions -Ordinary and preset creation write a short root `AGENTS.md` requiring the `one-cli` skill and directing agents to `one skills install` when it is missing. Skill installation is a separate user-level operation. Later `one add` calls preserve this file, including team edits. + +New workspaces include a concise bilingual `AGENTS.md` with 7 rules per language covering workspace discovery, CLI help, adding projects, tasks and verification, dependencies, environment variables, and automation error handling. Command details are discovered through `one --help` instead of embedding a full command manual. The file is team-owned; later `one add` operations preserve edits. diff --git a/apps/docs/content/docs/en/dev.md b/apps/docs/content/docs/en/dev.md index 7dfeb1d..b5adb83 100644 --- a/apps/docs/content/docs/en/dev.md +++ b/apps/docs/content/docs/en/dev.md @@ -1,24 +1,23 @@ --- -title: one dev -description: Start every developable project, or one selected project. +title: dev task +description: Run development tasks and their prerequisites through mise. --- -`one dev` reads each project's development command from the manifest and runs it through One CLI's built-in supervisor. +`one dev` is shorthand for `one run dev`. `dev` is an ordinary task name, just like `test` or `lint`, and uses the same task parsing, flags, and help. The effective task graph comes from mise, including your overrides in `mise.toml`. -## Usage - -```bash -one dev [project] [--dry-run] +```sh +one dev +one dev -p web -p api +one dev -p apps/web --dry-run -o json +one dev -p web --ui raw +one dev -p web -- --port 4300 ``` -## Options +Without `-p`, One invokes the root `dev` task. The generated aggregate includes projects with a dev task. Repeat `-p` to select projects by name or relative path. Pass task arguments after `--`, selecting one project when forwarding development arguments. + +## Commands and overrides -| option | purpose | -|---|---| -| positional `project` | start one project by manifest `name` or `relativeDir` | -| `-p`, `--project ` | legacy selector for scripts and CI | -| `--dry-run` | print the supervisor command without starting processes | -| `-o`, `--output ` | `json` / `yaml` / `text` | +Generated adapters use `projects[].dev.command` when set, otherwise the project's native `dev` script or Taskfile task. The root `mise.toml` can override a project task such as `web:dev` or replace the workspace aggregate. Custom mise commands use mise's environment. Generated adapters receive One's frozen project environment. ## Dependency preparation @@ -27,28 +26,15 @@ one dev [project] [--dry-run] - Node dependencies are prepared at the workspace root. With pnpm 10.14 or later, One asks pnpm to verify the installed workspace state and reuses a matching installation, including one created by a manual `pnpm install`. When installation is needed, it runs `pnpm install --no-frozen-lockfile` so new projects and dependency changes can update the lockfile. Older pnpm versions use One's installation cache. Other package managers keep their existing lockfile policy. - `one build` keeps the strict installation policy: an existing pnpm dependency lockfile uses `--frozen-lockfile`. If it is stale, install dependencies and review the lockfile changes before building. - Go preparation downloads the fixed module build list or resolves workspace dependencies, maintaining checksums as needed. It does not run `go mod tidy` or `go work sync` automatically. -- A failed preparation stops startup. Package-manager diagnostics are streamed once; canceling stops the preparation process. `one run` does not install dependencies. +- A failed preparation stops startup. Package-manager diagnostics are streamed once; canceling stops the preparation process. `one exec` does not install dependencies. -## Runner -One CLI's built-in supervisor starts all developable projects by default, or one positional project. - -```bash -one dev -one dev web -one dev apps/web --dry-run -``` +## Logs, input, and exit -## Common errors +Development allocates enough concurrency for the selected graph by default. Multiple services use prefixed mise logs; `--concurrency` must leave enough slots for all development services. Upstream finite builds finish before their dependent service starts. -| code | fix | -|---|---| -| `RUN_COMMAND_NOT_FOUND` | check the native tools or mise configuration | -| `ONE_CLI_ERROR` | fix the reported dependency error, then retry | -| `SUBPROJECT_NOT_FOUND` | use a project `name` or `relativeDir` | +`--ui raw` preserves native terminal input for one service and disables artifact caching. mise raw/interactive tasks take exclusive terminal access; run interactive services separately. Multiple development services cannot use raw output. The previous TUI, project picker, `--keep-going`, and single-service restart controls have been removed. -## Next +Ctrl+C or SIGTERM stops the invocation and its child processes. If one task fails, the invocation stops and returns the child exit code. JSON/YAML output uses `one-cli/task-result/v1`, with child logs on stderr. `--dry-run` uses static `one-cli/task-plan/v1` output without installing tools, loading secrets, or writing configuration. -- [Local dev orchestration](/en/tutorials/dev-local/) -- [one run](/en/docs/run/) -- [Workspace manifest](/en/docs/manifest/) +See [one run](/en/docs/run/) for common flags, task configuration, caching, and command-name conflicts. diff --git a/apps/docs/content/docs/en/env-vars.md b/apps/docs/content/docs/en/env-vars.md index 22247a0..a4ab80b 100644 --- a/apps/docs/content/docs/en/env-vars.md +++ b/apps/docs/content/docs/en/env-vars.md @@ -1,252 +1,92 @@ --- title: one env -description: "Full reference for multi-environment variables: set / get / list / pull." +description: Manage Infisical variables with set, unset, and list; inject them directly into commands. --- -`one env` manages monorepo environment variables across environments. There are two backends: +Infisical is One CLI's only managed environment source. Variables are fetched for command execution and injected into the child process. One CLI does not read, write, or export `.env` files. -- **dotenv** (default): local filesystem. Each project has overlays such as `.env`, `.env.`, `.env.local`, and `.env..local`. -- **infisical**: one workspace shares one Infisical project; environments such as dev / preview / prod are sections inside that project. +## Setup -For the full workflow and mental model, read [Environment variables guide](/en/tutorials/env-vars/). +New workspaces have no Infisical binding and can run without signing in. Run `one login`, then use `one env set` or `one env list` to initialize the binding on first use. You can also select an existing Infisical project in Dashboard workspace settings. -## Environment Model - -`manifest.environments.names` is the workspace environment list. `one create` defaults to `["dev","preview","prod"]`. `manifest.environments.default` is the fallback when `--env` is omitted; default is `dev`. Existing workspaces that use `staging` remain valid; the Dashboard's Preview binding maps to `staging` when that legacy name is present and `preview` is not. - -`--env` resolution: - -```text ---env flag -> manifest.environments.default -> environments.names[0] -``` - -`one env set FOO bar --env qa` can create a new environment. TTY mode asks for confirmation; non-TTY / `--yes` appends directly to `manifest.environments.names`. `get`, `list`, and `pull` are read-only: unknown env names return `ENV_UNKNOWN_ENVIRONMENT`. - -## Usage - -```bash -one env set [VALUE] [--env ] [-p ] [--yes] -one env get [--env ] [-p ] --reveal -one env list [--env ] [-p ] -one env pull [--env ] [-p ] [--force] [--dry-run] -``` - -`-p / --project` accepts a manifest project name or workspace-relative path: - -```bash -one env set FOO=bar -p web -one env set FOO=bar -p apps/web -one env set FOO=bar # auto-detects project from cwd -one env pull -p api # only pull api -one env pull --env staging # pull staging vars for all projects -``` - -The global output flag is `-o / --output`, with `json`, `yaml`, or `text`. - -> There is no `one env init` subcommand today. Infisical project binding is attempted by `one create --env-provider infisical`. If login, network, or permissions were not ready during create, the first `set/get/list/pull` retries lazy auto-bind. - -Machine-level Infisical credentials are configured with [`one login`](/en/docs/login/). They do not go into the manifest. - -## Interactive Mode - -Bare `one env` shows the current source, default/available environments, -current scope, and common commands. `one env set` has a safe TTY flow: - -- `one env set KEY` reads the value with hidden input. -- At the workspace root, it asks whether the variable is shared or belongs to a project. -- When writing to an environment that is not in `manifest.environments.names`, it asks whether to add that environment to the manifest. -- When overwriting an existing different value, it asks whether to overwrite. - -Declining or cancelling exits successfully without an error-style message. -Scripts and CI pass the value explicitly and use `--yes` for overwrite/new-environment confirmation. - -## dotenv Overlay - -The dotenv backend reads files in this order: - -```text -/.env -/.env. -/.env.local -/.env..local -``` - -Later files override earlier files. `one env set` writes `.env.`; `.local` files are read-only from One CLI's perspective and are maintained by developers. - -`one create` writes this to `.gitignore` by default: - -```text -.env -.env.* -!.env.example -``` - -## set - -Write one key. Both argument forms work: - -```bash -one env set DATABASE_URL "postgres://localhost/dev" --env dev -p api -one env set JWT_SECRET=dev-only-secret --env dev -p api --yes -``` - -Output schema: `one-cli/env-set/v1` +The binding lives in the top-level `env` field of `one.manifest.json`: ```json { - "schema": "one-cli/env-set/v1", - "env": "dev", - "key": "DATABASE_URL", - "action": "created" + "env": { + "siteUrl": "https://app.infisical.com", + "projectId": "your-project-id", + "projectName": "my-workspace", + "rootPath": "/" + }, + "environments": { + "names": ["dev", "preview", "prod"], + "default": "dev" + } } ``` -`action` can be `created`, `updated`, or `unchanged`. Existing different values require `--yes` to confirm overwrite. - -## get +The manifest records binding information, folder paths, and declared key names. Values stay in Infisical; authentication uses the browser session stored in the system keyring. -Read one key: +## Commands ```bash -one env get DATABASE_URL --env dev -p api --reveal -DB_URL=$(one env get DATABASE_URL --env dev -p api -o json | jq -r .value) --reveal +one env +one env set DATABASE_URL -p api +one env set API_URL=https://example.com -p web --env dev --yes +one env list -p web --env dev ``` -Output schema: `one-cli/env-get/v1` +`set KEY` prompts for a hidden value in a terminal. Scripts pass a value explicitly. `--yes` confirms overwrites and new environment names. `list` returns names only. The CLI has no plaintext read command; use `one exec` to inject values into a child process. `-o json` or `-o yaml` provides structured output with the stable schemas `one-cli/env-set/v1` and `one-cli/env-list/v1`. -```json -{ - "schema": "one-cli/env-get/v1", - "env": "dev", - "key": "DATABASE_URL", - "value": "postgres://..." -} -``` +`-p / --project` accepts a project name or workspace-relative path. Without it, One CLI infers the project from the current directory. At the workspace root, variables are shared; interactive `set` offers a scope selection. -## list +## Environments and folders -List key names without values: +`environments.names` declares the available environments. The default is `environments.default`, falling back to the first name. New workspaces declare `dev`, `preview`, and `prod` with `dev` as default. `--env` overrides this choice. -```bash -one env list --env dev -p api -``` +`set` can register a new environment with confirmation. `list` rejects names not declared in the manifest with `ENV_UNKNOWN_ENVIRONMENT`. These commands can still initialize an absent Infisical binding. -Output schema: `one-cli/env-list/v1` +Projects use their `relativeDir` as the default Infisical folder. Override this in `projects[].env`: ```json { - "schema": "one-cli/env-list/v1", - "env": "dev", - "keys": ["DATABASE_URL", "JWT_SECRET"] + "name": "api", + "relativeDir": "services/api", + "env": { + "path": "/teams/payments/api", + "inherits": true, + "keys": ["DATABASE_URL"] + } } ``` -## pull +With inheritance enabled (the default), variables merge from root through ancestor folders to the project folder; closer folders win. Set `inherits` to `false` to read only the project folder. `disabled: true` disables that project's Infisical injection. -Pull Infisical variables into local `.env`: +## Run with variables ```bash -one env pull --env dev -one env pull --env dev -p api --dry-run -one env pull --env dev --force -``` - -Without `-p`, One CLI iterates through `manifest.projects[]`. Each project maps to an Infisical folder from `projects[].domains.env.path` or `relativeDir`, merges root -> ancestors -> self inheritance, and writes the resulting `.env` into that project directory. - -Output schema: `one-cli/env-pull/v1` - -```json -{ - "schema": "one-cli/env-pull/v1", - "env": "dev", - "dry_run": false, - "written_count": 1, - "skipped_count": 0, - "per_subproject": [ - { - "name": "api", - "relative_dir": "services/api", - "infisical_path": "/services/api", - "env_file_path": "/abs/.../services/api/.env", - "status": "written", - "keys_written": ["DATABASE_URL", "JWT_SECRET"] - } - ] -} +one exec -p api --env dev -- go run ./cmd/server +one run dev --env dev ``` -## Manifest Configuration - -Workspace env backend lives in `one.manifest.json#domains.env`; environments live at top-level `environments`: +With an `env` binding, generated task adapters and `one exec` fetch Infisical variables and override matching shell variables. Without a binding, or with project injection disabled, they inherit the shell environment. Authentication or fetch failures stop execution; there is no local-file fallback. Custom mise tasks use mise's environment. One masks known injected values in stdout, stderr, and task-cache replay, including multiline values and their JSON-escaped forms. -```json -{ - "environments": { - "names": ["dev", "staging", "prod"], - "default": "dev" - }, - "domains": { - "env": { - "kind": "infisical", - "config": { - "siteUrl": "https://app.infisical.com", - "projectId": "...", - "projectName": "my-workspace", - "rootPath": "/" - } - } - } -} -``` - -Project path overrides live in `projects[].domains.env`: - -```json -{ - "projects": [ - { - "name": "charge", - "relativeDir": "services/charge", - "domains": { - "env": { - "path": "/teams/payments/charge", - "inherits": true - } - } - } - ] -} -``` - -Values never enter the Manifest. It records project identity, instance URL, folder paths, and key names. Authentication uses the single browser session in the system keyring. - -## Credential Safety +## Shared credentials -Use `one login`; the token lives only in the system keyring, outside project and ordinary configuration files. +Shared credentials are independent of workspaces. Select storage with `one env bind --global`, browse names with `one env list --global --env dev --path /`, and inject an explicit scope with `one exec --global --env dev --path /folder -- command`. See [login and shared credentials](/en/docs/login/). -## Common Errors +## Common errors | Code | Recovery | |---|---| -| `INFISICAL_NOT_CONFIGURED` | Confirm the workspace uses `--env-provider infisical` and you have signed in with `one login` | -| `INFISICAL_AUTH_MISSING` | Re-run `one login` | -| `INFISICAL_AUTH_FAILED` | Regenerate the client secret in Infisical | -| `INFISICAL_PROJECT_NAME_TAKEN` | Change `domains.env.config.projectName` and rerun an env command to trigger lazy bind | -| `INFISICAL_PROJECT_CREATE_FORBIDDEN` | Grant admin role to the machine identity, or manually create the project and fill `domains.env.config.projectId` | -| `ENV_PULL_CONFLICT` | Local `.env` exists and differs; add `--force` after confirming overwrite | -| `ENV_KEY_NOT_FOUND` | Check path, env name, and spelling | -| `ENV_INVALID_KEY` | Keys must match `^[A-Za-z_][A-Za-z0-9_]*$` | -| `ENV_SET_OVERWRITE_REQUIRED` | Existing value differs; add `--yes` to confirm | -| `ENV_UNKNOWN_ENVIRONMENT` | `set` can create; read commands require the env to exist in `manifest.environments.names` | - -Full table: [Error codes](/en/docs/error-codes/). - -## Next - -- [Environment variables guide](/en/tutorials/env-vars/) — mental model and complete workflow -- [`one create`](/en/docs/create/) — use `--env-provider infisical` during workspace creation - - -## Shared credentials - -Shared credentials are independent of workspaces. Select storage with `one env bind --global`, browse metadata with `one env list --global --env dev --path /`, and inject an explicit scope with `one run --global --env dev --path /folder -- command`. See [login and shared credentials](/en/docs/login/) for commands and security boundaries. +| `INFISICAL_NOT_CONFIGURED` | Bind an Infisical project in Dashboard or sign in and initialize it through an env command | +| `INFISICAL_AUTH_MISSING` / `INFISICAL_AUTH_FAILED` | Run `one login` and check access to the bound project | +| `INFISICAL_PROJECT_NAME_TAKEN` | Set a distinct `env.projectName`, or bind the existing project explicitly | +| `INFISICAL_PROJECT_CREATE_FORBIDDEN` | Select an existing accessible project in Dashboard | +| `ENV_KEY_NOT_FOUND` | Check key spelling, environment, and folder | +| `ENV_INVALID_KEY` | Use names matching `^[A-Za-z_][A-Za-z0-9_]*$` | +| `ENV_SET_OVERWRITE_REQUIRED` | Confirm the replacement with `--yes` | +| `ENV_UNKNOWN_ENVIRONMENT` | Register the environment with `set`, or select an existing name | + +See [manifest migration](/en/docs/manifest/) for older workspaces and [the walkthrough](/en/tutorials/env-vars/) for a first setup. diff --git a/apps/docs/content/docs/en/error-codes.md b/apps/docs/content/docs/en/error-codes.md index 8a35bdc..bc1e138 100644 --- a/apps/docs/content/docs/en/error-codes.md +++ b/apps/docs/content/docs/en/error-codes.md @@ -37,12 +37,6 @@ Field meanings: - **`error.context`**: structured data from the failure site. It often already contains the data needed for recovery. - **`error.remediation`**: recovery actions. Each item has `action`, `hint`, and sometimes `command`; agents should prefer these before guessing. -## Agent Skill Installation - -### `SKILLS_INSTALL_FAILED` - -The bundled `one-cli` skill could not be installed. Check the agent ID and destination permissions with `one skills install --help`. If some targets were installed before a failure, `context.installed_to` lists those directories. Retrying is safe. - ## Generic / Lifecycle Command-level failures, user cancellation, and internal serialization failures. @@ -160,25 +154,6 @@ A backend sync failed or rolled back after manifest write. Re-run the command af Backend selection and generated workspace configuration. -### `CI_DISABLE_CONFIRMATION_REQUIRED` - -A non-interactive `one ci disable` call did not pass `--yes`. Review the -selected projects and rerun with explicit confirmation; no workflow was removed. - -### `CI_PROVIDER_UNKNOWN` - -The requested CI provider is not implemented by this build. Use the IDs in -`error.context.available_providers`. - -### `CI_NOT_ENABLED` - -The selected project has no generated CI workflow. Run the command in -`error.remediation`, usually `one ci enable `, before syncing it. - -### `CI_RENDER_FAILED` - -CI backend failed while rendering workflow files. - ### `LOCAL_ORCH_PORT_CONFLICT` Two projects requested the same dev port and the runner could not auto-allocate another. @@ -218,7 +193,7 @@ Requested environment profile is missing or empty. ### `ENV_PULL_CONFLICT` -Existing `.env` differs from pulled values. Inspect first or use `--force` intentionally. +Reserved error code from the removed local environment-file workflow. ### `ENV_SET_KEY_REQUIRED` @@ -238,20 +213,19 @@ Env name is not registered. `set` can create it; read commands require it to exi ### `ENV_BACKEND_INVALID` -Env backend is not `dotenv` or `infisical`. +The Infisical binding is invalid. ### `ENV_BACKEND_UNCHANGED` -Workspace is already on the requested env backend. No action needed. +Reserved error code from the removed local environment-file workflow. ### `ENV_MIGRATE_CONFLICT` -Target backend has a same-name key with a different value. Use overwrite or no-sync intentionally. +Reserved error code from the removed local environment-file workflow. ### `ENV_MIGRATE_PARTIAL` -Some keys synced and others failed. Fix the cause and retry. - +Reserved error code from the removed local environment-file workflow. ## Infisical Backend @@ -344,7 +318,7 @@ Two backend patches conflict on the same target. ### `RUN_DOTENV_MISSING` -Local dotenv file required by `one run` is missing. +Reserved error code from the removed local environment-file workflow. ### `SERVE_BIND_FORBIDDEN` diff --git a/apps/docs/content/docs/en/exec.md b/apps/docs/content/docs/en/exec.md new file mode 100644 index 0000000..275f795 --- /dev/null +++ b/apps/docs/content/docs/en/exec.md @@ -0,0 +1,79 @@ +--- +title: one exec +description: Inject project env vars into any command and execute it from the resolved project directory. +--- + +`one exec` resolves a project, injects its Infisical variables when configured, and runs your command from the project directory. + +Workspace and global execution mask known injected secret values on stdout and stderr. Ordinary log formatting and child exit codes are preserved. Masking also covers multiline values and their JSON-escaped forms; it does not isolate arbitrary child programs. + +## Usage + +```bash +one exec [-p ] [--env ] -- [args...] +``` + +You can omit `--`, but scripts should keep it so child flags are not parsed as One CLI flags. + +## Options + +| option | purpose | +|---|---| +| `-p`, `--project ` | select a project; without it, One CLI infers from cwd | +| `--env ` | use a specific environment | +| `-o`, `--output ` | affects only One CLI output; child stdout/stderr pass through | + +## Interactive Mode + +`one exec` has no wizard. It only resolves the project, environment, and child command from arguments. Keep the `--` separator in scripts because the child command can have its own flags. + +## Examples + +```bash +one exec -- pnpm test +one exec -p web -- pnpm run build +one exec -p apps/web -- pnpm lint +one exec --env staging -- pnpm run e2e +``` + +The child process always runs from the resolved project directory, so commands find that project's `package.json`, `Taskfile.yml`, or Go module files. + +## PATH and env + +`one exec` merges loaded variables into the child environment, overriding same-name shell variables. It also prepends: + +```text +/node_modules/.bin +/node_modules/.bin +``` + +This lets pnpm / turbo workspaces invoke `vite`, `next`, `astro`, and similar binaries directly. + +## Infisical injection + +The top-level manifest `env` binding enables Infisical injection. Without a binding, or with `projects[].env.disabled: true`, the command inherits the shell environment. One CLI does not load `.env` files. Authentication or fetch failures stop execution instead of falling back to local files. Use `one login` to authenticate. + +## Common errors + +| code | fix | +|---|---| +| `NOT_ONE_PROJECT` | run inside a workspace or project directory | +| `SUBPROJECT_NOT_FOUND` | pass a manifest `name` or `relativeDir` to `-p` | +| `RUN_COMMAND_NOT_FOUND` | check PATH, project `node_modules/.bin`, and workspace `node_modules/.bin` | +| `INFISICAL_AUTH_MISSING` | run `one login` | + +## Next + +- [Run with env vars](/en/tutorials/run-passthrough/) +- [one env](/en/docs/env-vars/) +- [one dev](/en/docs/dev/) + + +## Global credentials + +```bash +one exec --global --env dev --path /oss --keys OSS_ACCESS_KEY_ID,OSS_ACCESS_KEY_SECRET -- upload-assets +one exec --global --env dev --path /oss --dry-run -- upload-assets +``` + +Environment and folder must be explicit. Only that folder is read; `--keys` fetches only selected variables. Dry-run does not read credentials. Global mode does not load project environments or implicitly resolve repository binaries. It preserves the current directory. Exact-value output masking is best effort, not a sandbox. diff --git a/apps/docs/content/docs/en/hk.md b/apps/docs/content/docs/en/hk.md new file mode 100644 index 0000000..484c084 --- /dev/null +++ b/apps/docs/content/docs/en/hk.md @@ -0,0 +1,45 @@ +--- +title: one hk +description: Workspace checks, explicit fixes, and Git commit checks with hk. +--- + +`one hk` runs the pinned hk version through mise. One starts hk at the workspace root and forwards its arguments, output, and exit code. + +```bash +one hk check # Check changed files +one hk check --all # Check the whole workspace, including in CI +one hk check --plan --json # Preview checks +one hk fix # Fix files without staging them +one hk validate # Validate the configuration +``` + +New workspaces install local `pre-commit` and `commit-msg` launchers. After cloning a workspace or moving your One installation, run `one init hooks` to install or refresh the launchers. + +## Configuration + +- `.config/hk.pkl` is the workspace's hk configuration. hk discovers it from the workspace root. Project paths in checks remain relative to that root. +- Root `mise.toml` pins the hk version. +- Git launchers are installed in the repository's Git hooks directory. + +Edit `.config/hk.pkl` directly. `one add` and `one init hooks` update unchanged defaults while preserving your edits and comments. If you and One change the same check differently, the operation reports `HOOKS_CONFIG_CONFLICT` and preserves the existing file. + +Keep the `// one:begin`, `// one:end`, `// one:insert`, and `// one:managed-v1` tracking comments. Add custom checks inside `steps`, outside generated entry markers. To delete a default check, remove its matching begin/end markers along with its body; later refreshes preserve that deletion. An existing `.config/hk.pkl` without One markers stays fully user-owned. + +An existing root `hk.pkl` takes precedence over `.config/hk.pkl`. Merge its settings into `.config/hk.pkl` and remove the root file before configuring One hooks. Extra hook events require their own Git launchers. + +## Defaults + +Pre-commit checks staged content, temporarily stashing and restoring unstaged changes. It does not fix or stage files automatically. Commit messages use hk's built-in Conventional Commits check. Go projects use `gofmt`; Node projects use declared oxlint / oxfmt tools or explicit `lint` and `format:check` scripts. + +Run `one hk fix`, review the changes, and stage them again before retrying a failed commit. Node checks require project dependencies; install them with `one mise exec -- pnpm install` at the workspace root. + +## Preview and refresh + +```bash +one init hooks --dry-run -o json +one init hooks +``` + +Only recognized default Husky / commitlint files are migrated automatically. Custom rules and hook directories produce a conflict for manual integration. Reinstall Node dependencies afterward to update the lockfile. + +For CI, prepare One and project dependencies, then run `one hk check --all`. diff --git a/apps/docs/content/docs/en/installation.md b/apps/docs/content/docs/en/installation.md index 0cc7410..f5f6401 100644 --- a/apps/docs/content/docs/en/installation.md +++ b/apps/docs/content/docs/en/installation.md @@ -72,6 +72,16 @@ one --version On Windows, the archive is `one-cli_windows_amd64.zip`. +## Automatic Updates + +Stable release builds start an independent background updater at most once every 24 hours during normal terminal use. It downloads the platform archive and `checksums.txt` from the official GitHub Release, verifies SHA256 and the executable version, then replaces the installed program. The current command keeps running; subsequent invocations use the new version. On Windows, replacement waits for the command that started the update to exit. + +Download, verification, or write failures preserve the installed program. When a newer release is known, One shows the failure and a manual update command. If the executable changes during the download, such as after a local rebuild, the updater refuses to overwrite it. Failed attempts also retry on the 24-hour interval. + +**Development builds skip update checks, downloads, installation, and notifications entirely.** Builds from `go build`, `mise run build`, `mise run build-local`, and `mise run install` default to updates disabled, even when `RELEASE_VERSION` is set. Only the release packager enables the update marker, and a complete stable version is also required. Development, local, snapshot, and other prerelease versions never update automatically. + +CI, structured JSON/YAML output, `--dry-run`, and internal task processes do not start updates. Update state lives at `$XDG_CACHE_HOME/one/update-check.json`, defaulting to `~/.cache/one/update-check.json`. + ## Upgrade And Downgrade `install.sh` and `install.ps1` check the installed `one --version` before deciding what to do: @@ -105,9 +115,9 @@ Managed mise updates with One; automatic self-updates are disabled for that chil **Offline use:** a valid external, managed, or migratable legacy executable can be reused offline. A fresh environment without any of them needs network access. Prepare mise and the required tools/dependencies beforehand, or point `ONE_MISE_BINARY` to a compatible external executable. `ONE_RUNTIME=builtin` is a temporary diagnostic escape hatch using existing tools. -Download, migration, or verification failures return `MISE_INSTALL_FAILED`. Check network/proxy access to GitHub Releases and permissions on One's runtime directory, then retry the same command. Help, dry-run, and static project/configuration generation do not prepare mise. +Download, migration, or verification failures return `MISE_INSTALL_FAILED`. Check network/proxy access to GitHub Releases and permissions on One's runtime directory, then retry the same command. Help, dry-run, and configuration planning do not prepare mise. Creation and project addition prepare it to trust complete One-generated configurations; a failure returns a warning while preserving the generated files. -Use `one mise --version`, `one mise doctor`, or `one mise trust ` to work with the same selected runtime. Review configuration before trusting it; `MISE_PARANOID=1` requires explicit trust. Arguments, IO, and exit codes are forwarded, without One project secrets; use `one run` when those secrets are needed. +Use `one mise --version`, `one mise doctor`, or `one mise trust ` to work with the same selected runtime. Creation and project addition register file-specific trust for complete One-generated configurations, including with `MISE_PARANOID=1`. Custom configuration still requires review and follows mise’s trust policy. Arguments, IO, and exit codes are forwarded, without One project secrets; use `one exec` when those secrets are needed. ## Infisical login @@ -157,8 +167,8 @@ If you are changing One CLI itself, read [CONTRIBUTING.md](https://github.com/1c ```bash git clone https://github.com/1cli-team/one-cli cd one-cli -brew install go go-task # macOS; adapt for Linux -task install # package Dashboard + CLI, then create a native launcher +brew install mise # macOS; adapt for Linux +mise run install # package Dashboard + CLI, then create a native launcher hash -r which one one --version @@ -173,7 +183,3 @@ For the full contributor flow, see [CONTRIBUTING.md](https://github.com/1cli-tea ## Installed? Go to [Quick start](/en/docs/quick-start/) and create your first workspace. - -## Agent skill - -Run `one skills install` to install the bundled `one-cli` skill into your coding agent's user skills directory. Use `one skills install --help` for target selection and supported agents. See [Skills](./skills). diff --git a/apps/docs/content/docs/en/login.md b/apps/docs/content/docs/en/login.md index 53dfdc3..936c1e1 100644 --- a/apps/docs/content/docs/en/login.md +++ b/apps/docs/content/docs/en/login.md @@ -26,12 +26,12 @@ one env bind --global --project-id PROJECT_ID --env dev one env --global one env list --global --env dev --path / one env list --global --env dev --path /docker -one run --global --env dev --path /docker --keys REGISTRY_USER,REGISTRY_PASSWORD -- docker-push-script +one exec --global --env dev --path /docker --keys REGISTRY_USER,REGISTRY_PASSWORD -- docker-push-script ``` Listings contain immediate folders, names, and descriptions, without values. Execution requires an explicit environment and path. It does not recurse, import other folders, or expand secret references. Use `--keys` to narrow injection further. Global mode works outside a workspace and preserves the current directory. Project commands, `one dev`, and `one build` do not automatically receive shared credentials. -Read plaintext explicitly with `one env get KEY --global --env dev --path /docker --reveal`. Write with the interactive password prompt or `one env set KEY --global --env dev --path /docker --stdin`. Overwrites require `--yes`. Delete with `one env unset KEY --global --env dev --path /docker`. +The CLI never displays secret values. Inject shared credentials with `one exec --global --env dev --path /docker -- command`. Write with the interactive password prompt or `one env set KEY --global --env dev --path /docker --stdin`. Overwrites require `--yes`. Delete with `one env unset KEY --global --env dev --path /docker`. ## Dashboard diff --git a/apps/docs/content/docs/en/manifest.md b/apps/docs/content/docs/en/manifest.md index 0bc8636..670420d 100644 --- a/apps/docs/content/docs/en/manifest.md +++ b/apps/docs/content/docs/en/manifest.md @@ -10,12 +10,18 @@ Every One CLI workspace has a `one.manifest.json` at its root. It records the wo ```json { "version": 1, - "workspace": { "id": "demo-app-2bb61e", "name": "demo-app" }, + "workspace": { + "id": "demo-app-2bb61e", + "name": "demo-app" + }, "environments": { - "names": ["dev", "preview", "prod"], + "names": [ + "dev", + "preview", + "prod" + ], "default": "dev" }, - "domains": { "env": { "kind": "dotenv" } }, "projects": [ { "name": "web", @@ -24,9 +30,15 @@ Every One CLI workspace has a `one.manifest.json` at its root. It records the wo "toolchain": "node", "buildVersion": "0.1.0", "packageManager": "pnpm", - "domains": { - "env": { "path": ".env", "inherits": true, "keys": ["API_URL"] }, - "dev": { "command": "pnpm dev" } + "env": { + "path": "/apps/web", + "inherits": true, + "keys": [ + "API_URL" + ] + }, + "dev": { + "command": "pnpm dev" } }, { @@ -34,9 +46,16 @@ Every One CLI workspace has a `one.manifest.json` at its root. It records the wo "templateId": "go-api", "relativeDir": "services/api", "toolchain": "go", - "domains": { "dev": { "command": "go run ./cmd/server" } } + "dev": { + "command": "go run ./cmd/server" + } } - ] + ], + "env": { + "siteUrl": "https://app.infisical.com", + "projectId": "your-project-id", + "rootPath": "/" + } } ``` @@ -49,28 +68,27 @@ The real file is strict JSON; comments and unknown fields are rejected. | `version` | Manifest schema version, currently `1` | | `workspace` | Stable `id` and display `name` | | `environments` | Environment names and default environment | -| `domains.env` | Workspace environment source: `dotenv` or `infisical`, with optional backend-specific `config` | +| `env` | Optional Infisical binding: `siteUrl`, `projectId`, `projectName`, `rootPath`, and `keys` | | `projects[]` | Project names, paths, templates, toolchains, optional `packageManager` and `buildVersion` | -| `projects[].domains.env` | Environment overrides: `path`, `inherits`, `disabled`, and declared key names; inherits the workspace backend | -| `projects[].domains.dev` | The `command` executed by `one dev` | +| `projects[].env` | Environment overrides: `path`, `inherits`, `disabled`, and declared key names; inherits the workspace backend | +| `projects[].dev` | The `command` executed by `one dev`, plus an optional local Dashboard access `url` | -For Infisical, `domains.env.config` can contain `projectId`, `projectName`, `rootPath`, and `keys`. Key values and local Profile names never belong in the manifest. Keep credentials in machine-local Profiles and values in dotenv or Infisical. +For Infisical, `env` can contain `projectId`, `projectName`, `rootPath`, and `keys`. Key values and local Profile names never belong in the manifest. Keep credentials in the system keyring and values in Infisical. ## Who writes it | Action | Change | |---|---| -| `one create` | Writes workspace identity, default environments, environment source, and an empty project list | +| `one create` | Writes workspace identity, default environments, and an empty project list; leaves `env` unset | | `one add` | Registers a project and its development command | | `one env set` | Records declared key names; Infisical can initialize its project binding | -| `one env switch` | Changes the workspace environment source | | `one serve` | Applies explicitly reviewed project or environment-source changes with revision checks | -`one build` selects each project's build command from its toolchain. Node projects use package scripts; Go projects use `Taskfile.yml`. CI is managed separately with `one ci`. +`one build` selects each project's build command from its toolchain. Node projects use package scripts; Go projects use `Taskfile.yml`. Workspace tasks and the ordinary `ci` aggregate run through `one run`. ## Manual edits -Keep paths and names consistent when renaming or removing projects. Update `projects[].domains.dev.command` if the project's development script changes. The workspace layout remains `apps/`, `services/`, and `packages/`. +Keep paths and names consistent when renaming or removing projects. Update `projects[].dev.command` if the project's development script changes. The workspace layout remains `apps/`, `services/`, and `packages/`. If the manifest and filesystem disagree, inspect the declared paths and restore the missing project files or fix the registry entry. Do not put business values, dependencies, caches, or build outputs in the manifest. @@ -79,3 +97,9 @@ If the manifest and filesystem disagree, inspect the declared paths and restore The `deploy` and `container` domains have been removed. A manifest containing those fields returns `MANIFEST_INVALID` with a removal hint. Delete the corresponding fields manually; no migration or cleanup of existing Dockerfiles, platform configuration, or CI files is performed. Invalid JSON or unknown fields also produce `MANIFEST_INVALID`. See [error codes](/en/docs/error-codes/). + +## Migration from domains + +The `domains` wrapper is no longer accepted. Move an Infisical workspace binding from `domains.env.config` to top-level `env`, and move project `domains.env` / `domains.dev` to `env` / `dev`. Remove `kind` and `config` wrappers. For a former dotenv workspace, remove the old binding and any local-file `path` overrides; bind Infisical when needed. + +Old manifests return `MANIFEST_INVALID` with a migration hint. One CLI does not rewrite manifests or import/delete existing `.env` files. Move required values to Infisical explicitly. The env `pull` and `switch` subcommands and the `--env-provider` flag have been removed. Legacy preset code `d` remains reserved and is rejected; use `i` or omit the environment segment. diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index 1fd02be..4600454 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -6,7 +6,6 @@ "manifest", "templates", "ai-native", - "skills", "cli-overview", "create", "add", @@ -15,8 +14,9 @@ "templates-cmd", "dev", "build", - "ci", "run", + "exec", + "hk", "serve", "error-codes" ] diff --git a/apps/docs/content/docs/en/run.md b/apps/docs/content/docs/en/run.md index 4e74e6b..e171f32 100644 --- a/apps/docs/content/docs/en/run.md +++ b/apps/docs/content/docs/en/run.md @@ -1,86 +1,133 @@ --- title: one run -description: Inject project env vars into any command and execute it from the resolved project directory. +description: Discover and execute workspace tasks with mise. --- -`one run` behaves like `infisical run` / `dotenv run`: it resolves a project, loads env vars from the workspace env provider, injects them into a child process, and runs the command you pass. +One selects projects and freezes their environment variables. mise schedules the task graph. Node projects define commands in `package.json` scripts and run them with pnpm; Go projects define them in Taskfile and run them with Task. + +```sh +one run # List tasks without executing them +one run --list -p web -o json +one run build # Build the workspace and local dependencies +one run build -p web -p api +one run test -p api -- -run TestHandler +one run check --env prod +one run ci +one run build --dry-run -o json +``` -## Usage +## Task shorthand -```bash -one run [-p ] [--env-provider dotenv|infisical] [--env ] -- [args...] -``` +`one ` is shorthand for `one run `. `dev`, `build`, `test`, and `lint` are ordinary task names and all follow this rule. For example, `one test -p api` and `one run test -p api` execute the same task. Built-in commands and aliases take precedence: `one env` opens environment management, while `one run env` executes a task named `env`. Unknown names resolve as tasks and report a missing-task error when absent. `one --help` shows One's common task flags; use `-- --help` to forward help to the underlying command. -You can omit `--`, but scripts should keep it so child flags are not parsed as One CLI flags. +## Selection and arguments -## Options +Without `-p`, a task selects the workspace aggregate. Repeat `-p` to select project names or workspace-relative paths. Everything after `--` is forwarded to one selected task; forwarding arguments to multiple projects is rejected. `one build -p web` shares the execution path of `one run build -p web`. -| option | purpose | -|---|---| -| `-p`, `--project ` | select a project; without it, One CLI infers from cwd | -| `--env-provider dotenv|infisical` | force a provider instead of using the workspace manifest | -| `--env ` | use a specific environment | -| `-o`, `--output ` | affects only One CLI output; child stdout/stderr pass through | +`one exec web -- pnpm add axios` runs an arbitrary command. It does not invoke a task graph or install application dependencies. -## Interactive Mode +`:::` is reserved by mise for separating tasks and cannot be forwarded as a task argument. -`one run` has no wizard. It only resolves the project, environment, and child command from arguments. Keep the `--` separator in scripts because the child command can have its own flags. +## Configuration -## Examples +One writes tool versions, task adapters, aggregates, and local Node build dependencies into a single `mise.toml` at the workspace root. Project directories keep their native command files; One does not generate project-level mise configurations. Edit the root file directly to add tasks or adjust settings. Running tasks, adding projects, or calling `one init mise` adds missing configuration and updates generated fields that you have not changed. Custom fields and comments are preserved. Root `dev`, `build`, `check`, and `test` aggregate only available project tasks; `ci` combines build, check, and test. + +Keep the `# one:managed-v1` comment at the end of the file. It records the last generated fields for incremental updates. If you and One change the same field to different values, One reports `MISE_CONFIG_CONFLICT` with the field name. Adding comments, custom tasks, or changing defaults does not trigger a whole-file ownership error. + +```text +workspace/ + one.manifest.json + mise.toml # Tools, project task adapters, aggregates, cache settings + .config/hk.pkl # Workspace Git checks + apps/web/ + package.json # pnpm scripts + services/api/ + Taskfile.yml # Go project commands +``` + +Preview and refresh configuration: ```bash -one run -- npm test -one run -p web -- npm run build -one run -p apps/web -- pnpm lint -one run --env-provider dotenv -- npm test -one run --env staging -- npm run e2e +one init mise --dry-run -o json +one init mise ``` -The child process always runs from the resolved project directory, so commands find that project's `package.json`, `Taskfile.yml`, or Go module files. +Project tasks use root names such as `web:build` (canonical name `//:web:build`), with `dir = "apps/web"`. Tool versions are pinned at the root; Go defaults to the highest version required by workspace modules. Local Node dependencies with a build task run before dependent build, check, test, typecheck, and dev tasks. Go resolves module dependencies through `go.work` and its compiler. New Node workspaces use pnpm exclusively. -## PATH and env +List and dry-run use static configuration and never execute mise, install tools, read secrets, or write files. Dynamic task expressions and parameterized dependencies cannot be previewed. Actual runs also read mise's effective task catalogue. Native mise tasks with their own commands use mise's environment; generated project adapters use One's project environment. -`one run` merges loaded variables into the child environment, overriding same-name shell variables. It also prepends: +## Define a task -```text -/node_modules/.bin -/node_modules/.bin +Keep project commands in `package.json` scripts or a Taskfile. For example, adding `"docs:build": "typedoc"` makes `one run docs:build -p lib` available. Define workspace tasks in the root `mise.toml`: + +```toml +[tasks.verify] +depends = ["//:web:check", "//:api:test"] + +[tasks.hello] +description = "Print a greeting" +run = "echo hello" ``` -This lets pnpm / turbo workspaces invoke `vite`, `next`, `astro`, and similar binaries directly. +Run the aggregate with `one run verify`, or the custom command with `one run hello`. You can also put executable scripts in `.mise/tasks/`, or load a separate task file with `[task_config] includes = ["tasks.toml"]`. -## Env provider +Inspect native configuration with `one mise tasks ls --all --local` and `one mise tasks info //:web:build --json`. Custom native tasks can run directly with `mise run hello`; generated project tasks need `one run` to prepare their environment context. -| provider | behavior | -|---|---| -| `dotenv` | read project `.env` overlays | -| `infisical` | fetch env vars from Infisical | -| empty | use the provider recorded in the workspace manifest | +## Cache -`--env-provider infisical` requires browser login with `one login`. Use `--env-provider dotenv` for offline local runs. +Generated configuration enables mise's experimental features. Known template **build** tasks receive `sources`, `outputs`, and an environment fingerprint. Other tasks stay uncached until they have an explicit cache contract. Changing a template build script or build configuration disables automatic caching; declare the actual inputs and outputs under the project task in the root `mise.toml` to enable it again. Cache storage and artifact restoration belong to mise. -## Common errors +```sh +one run build --cache local-only +one run build --cache off --force +one run build --cache read-only +one run build --concurrency 4 +``` -| code | fix | -|---|---| -| `NOT_ONE_PROJECT` | run inside a workspace or project directory | -| `SUBPROJECT_NOT_FOUND` | pass a manifest `name` or `relativeDir` to `-p` | -| `RUN_COMMAND_NOT_FOUND` | check PATH, project `node_modules/.bin`, and workspace `node_modules/.bin` | -| `ENV_FILE_NOT_FOUND` | create a project `.env` or use `--env-provider infisical` | -| `INFISICAL_AUTH_MISSING` | run `one login` | +`--cache off --force` ensures execution even if source freshness checks would skip a task. Cache modes also include `read-write` and `write-only`; remote access requires your own mise cache backend configuration. One does not provision a remote service. -## Next +To opt a generated task into caching, declare its complete inputs and outputs under the project task in the root `mise.toml`, including One's fingerprint command: -- [Run with env vars](/en/tutorials/run-passthrough/) -- [one env](/en/docs/env-vars/) -- [one dev](/en/docs/dev/) +```toml +[tasks."web:build"] +dir = "apps/web" +sources = ["src/**/*", "package.json", "tsconfig.json", "../../pnpm-lock.yaml"] +outputs = ["dist"] +cache = { enabled = true, env = ["NODE_ENV"], command_inputs = ['one __task-input --project "web" --task "build"'] } +``` +Source and output paths are relative to the task’s `dir`. mise also hashes the defining configuration, so editing the root `mise.toml` invalidates caches for tasks defined there. -## Global credentials +`outputs = []` is appropriate for a deterministic task that only verifies inputs. Declare every upstream source, external file, configuration value, and compiler input that affects a custom task. Outputs must stay inside the task directory and must not overlap another selected task's outputs. Use `--cache off --force` while checking a new cache declaration. -```bash -one run --global --env dev --path /oss --keys OSS_ACCESS_KEY_ID,OSS_ACCESS_KEY_SECRET -- upload-assets -one run --global --env dev --path /oss --dry-run -- upload-assets +One loads each managed project's environment once per invocation, before cache lookup, into a temporary context. The fingerprint and command consume that same snapshot. Environment values remain out of generated TOML and structured results. Every injected project variable contributes to the fingerprint; authentication credentials used only to fetch variables do not. Directly invoking a generated adapter through `mise run` requires that context, so use `one run` for these tasks. + +## Terminals and output + +Tasks use streamed mise output. `--ui raw` preserves native terminal input and disables artifact caching. Multiple development services use prefixed logs and automatically allocated concurrency; raw/interactive services must run separately. `--ui tui` is unavailable. `one dev` and `one run dev` share the same execution path, including upstream builds. See [one dev](/en/docs/dev/) for development behavior. + +JSON/YAML previews use `one-cli/task-plan/v1`; execution uses `one-cli/task-result/v1`. Child logs go to stderr in structured mode. The result reports the overall status and exit code. Per-task status remains `unknown` because the selected mise version does not provide reliable structured completion events. One does not infer cache hits from console text. + +## GitHub Actions + +Keep your workflow in the repository and invoke the same tasks as local development. Once One is installed, a minimal job can use: + +```yaml +- uses: actions/checkout@v7 +- uses: jdx/mise-action@v4 + with: + version: 2026.9.7 + experimental: true +- uses: actions/cache@v4 + with: + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml', '**/go.sum', '**/mise.toml') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- +- run: one run ci --ui stream + env: + MISE_TASK_CACHE_DIR: ${{ github.workspace }}/.cache/mise-task-artifacts ``` -Environment and folder must be explicit. Only that folder is read; `--keys` fetches only selected variables. Dry-run does not read credentials. Global mode does not load project environments or implicitly resolve repository binaries. It preserves the current directory. Exact-value output masking is best effort, not a sandbox. +The cache directory must be excluded from task sources and version control. Cache keys isolate runner platforms; mise validates individual artifact keys. For workflows reading secrets, apply the repository's normal permissions and branch trust rules. + +See [mise task caching](https://mise.jdx.dev/tasks/caching.html) and [mise configuration](https://mise.jdx.dev/tasks/task-configuration.html) for native options. diff --git a/apps/docs/content/docs/en/serve.md b/apps/docs/content/docs/en/serve.md index 874e76a..bc152d4 100644 --- a/apps/docs/content/docs/en/serve.md +++ b/apps/docs/content/docs/en/serve.md @@ -15,3 +15,13 @@ Shared credentials can initialize a default location, create a Secret Manager pr Workspace overview shows each build command and its configuration source. Workspace Infisical project bindings and project configuration use one reviewed Manifest draft. Remote values never enter the Manifest draft or repository. Browsing an environment does not change the default. See [login and local settings](/en/docs/login/) for CLI usage and security boundaries. + +## Development services and console + +Run `one serve` in a terminal, then open a project's **Run** tab to start, stop, or restart its development service. Startup uses the effective `one dev` task, tool and dependency preparation, and Infisical variables for the selected environment. Each project has one instance; different projects can run independently. Save or discard pending Manifest drafts first. + +The console streams output with known secret values redacted, with ANSI colors, search, copy, and paused scrolling. It retains up to 256 KiB / 2,048 chunks. Refreshing reconnects; closing the page leaves services running. Interactive or raw tasks still need an external terminal; this console does not accept command input. + +Local HTTP(S) addresses are detected from logs. An optional URL can also be set in Overview (`projects[].dev.url`), without credentials, query parameters, or fragments. Access links become available when the port is reachable; this is a connectivity check, not an application health check. Services without a web page can run too. + +The environment selector affects the next start or restart; existing instances retain their environment. Stopping cleans up the process tree. Ctrl-C stops the Dashboard and every service it started. State and logs are held in memory; restarting the Dashboard does not restore services. diff --git a/apps/docs/content/docs/en/skills.md b/apps/docs/content/docs/en/skills.md deleted file mode 100644 index 09cc06f..0000000 --- a/apps/docs/content/docs/en/skills.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: one skills install -description: Install the minimal One CLI skill for your coding agent. ---- - -The bundled `one-cli` skill has two responsibilities: follow [One Workspace Convention](https://github.com/1cli-team/one-workspace-convention), and discover supported workspace operations through the installed CLI's help. Command lists, flags, templates, and manifest schemas stay with the CLI rather than being copied into the skill. - -```bash -one skills install -one skills install --agent claude-code --agent codex -one skills install --yes -o json -``` - -## Target selection - -- `--agent ` / `-a ` selects a target explicitly, even before that agent has created its configuration directory. Repeat the option for multiple agents. -- With no explicit targets, an interactive terminal offers detected agents, all preselected. -- `--yes` / `-y` or non-interactive execution installs into all detected agents without prompting. -- If no targets are detected, the command fails and asks for an explicit `--agent`. -- `one skills` and `one skills install --help` only display help. The latter lists every supported agent ID. - -Detection uses user configuration directories. Agent IDs from the previous installer remain supported. `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, and `VIBE_HOME` override their respective default roots when set to absolute directories. - -## Installation - -Installation copies the embedded `one-cli/SKILL.md` into the selected agents' user skills directories. It works offline and from any directory. Duplicate targets sharing a directory are written once. Success output lists the destination directories; the skill is in `one-cli/SKILL.md` below each destination. - -Repeating installation replaces that `one-cli` directory, including old reference files. Other skills stay intact. A legacy `one-cli` symlink is replaced with a direct copy without changing its shared store or other agents' links. If an installation fails after some targets succeed, the error's `context.installed_to` lists completed destinations; rerun the command after fixing the reported problem. - -After installation, load the installed skill. Restart the agent session if it does not discover new skills automatically. - -## Generated workspaces - -`one create`, including preset creation, writes a short root `AGENTS.md`: - -```markdown -# Development - -Use the `one-cli` skill when developing this workspace. -If it is not installed, run `one skills install` first. -``` - -Creation does not install skills into your home directory. `one add` preserves existing instructions. For an existing workspace, install the skill and add the same guidance to its `AGENTS.md`. - -## Updates and output - -Ordinary CLI upgrades do not require reinstalling this skill: it directs agents to `one --help`, `one help --all`, and the relevant command help. Reinstall only when the skill's guidance itself changes or when configuring another agent. - -Structured output uses `one-cli/skills-install/v1`, with `status`, `targets`, `installed_to`, and `skill_count` (one). Use the usual `-o json`, `-o yaml`, or `-o text` option. Installation failures use `SKILLS_INSTALL_FAILED`. diff --git a/apps/docs/content/docs/en/templates.md b/apps/docs/content/docs/en/templates.md index dfe4976..f14ebea 100644 --- a/apps/docs/content/docs/en/templates.md +++ b/apps/docs/content/docs/en/templates.md @@ -117,3 +117,83 @@ must be adjusted at the root. For concurrent desktop development, set a differen These rules apply to newly generated projects. Existing Electron projects are not automatically rewritten or migrated. + + +## Develop directly in a template directory + +Bundled templates contain ordinary source files that you can run and debug in +`packages/templates/`. Go templates have a local `go.work` to isolate them +from the repository workspace. Electron has a `pnpm-workspace.yaml` for template +development. These development files are excluded from generated projects, +which use the destination One workspace's configuration. + +For example, inside the One CLI source repository: + +```sh +cd packages/templates/go-api +go test ./... +go run ./cmd/server +``` + +The Go API defaults to in-memory SQLite. Configure environment variables as +described by the template when using PostgreSQL or other runtime settings. + +```sh +cd packages/templates/electron-app +pnpm install +pnpm run dev +``` + +Electron builds preload first, then starts the main process and Vite UI. The +host still needs Electron's graphical environment and system sandbox support. +Use `pnpm run build` for a build without launching the application. + +For a single-package Node template such as React, isolate the parent workspace: + +```sh +cd packages/templates/react-spa +pnpm --ignore-workspace install +pnpm --ignore-workspace run dev +``` + +Template development uses the pnpm version in its `package.json`. Generated +projects inherit the destination workspace's version. Local dependencies, +lockfiles and build artifacts are excluded from the CLI bundle. + +## Configure template generation + +Files are copied verbatim by default. Only starters needing parameterization +include a `template.json` file, with these supported settings: + +| Setting | Purpose | +| --- | --- | +| `schemaVersion: 1` | Declare the descriptor version | +| `go.modulePrefix` | Set the generated module path and rewrite its Go imports | +| `node.scope`, `node.sourceFiles` | Rename internal Node packages, dependency keys, scripts and the scope in listed source files | +| `text` | Replace example text in explicitly listed files | +| `exclude` | Exclude files or directories used only for template development | + +Each `text` rule contains `files`, `from` and `value`. The supported values are +`projectName` and `projectNameKebabCase`. Optional `minMatches` defaults to 1; +every listed file must meet that count. Paths are exact, template-relative paths. +No scripts or expressions are executed. Unknown fields, missing files, +insufficient matches and overlapping replacements fail before destination writes. + +Go templates maintain one normal `go.mod` and a `go.sum` when needed. Bundling +temporarily renames `go.mod` to `_go.mod` to avoid Go's nested-module embedding +restriction; generation restores the filename. Do not edit the generated +resources in `packages/cli/internal/resources/bundled/` manually. + +After editing, run these commands from the repository root: + +```sh +mise run sync-bundled +mise run check +mise run check:templates +``` + +`check:templates` installs the Electron template dependencies, builds the Go and +Electron sources, checks generated formatting for every Node template, and +builds two differently named Electron projects plus two Go projects in a +temporary workspace. It requires network access and the corresponding +toolchains, and does not change the developer's global language preference. diff --git a/apps/docs/content/docs/zh/add.md b/apps/docs/content/docs/zh/add.md index 371d156..cf04711 100644 --- a/apps/docs/content/docs/zh/add.md +++ b/apps/docs/content/docs/zh/add.md @@ -3,9 +3,9 @@ title: one add description: 往工作区里加一个模板化项目。 --- -工作区已启用 hk 时,`one add` 会同步更新语言检查:Go 加入格式检查,JS/TS 根据项目工具加入 lint 和格式检查。用户的 `hk.pkl` 保留不变。旧工作区可先通过 `one init hooks` 启用,详见 [`one hk`](/zh/docs/hk/)。 +工作区已启用 hk 时,`one add` 会同步更新语言检查:Go 加入格式检查,JS/TS 根据项目工具加入 lint 和格式检查。检查配置直接保存在 `.config/hk.pkl`,用户修改和注释会保留。工作区和项目任务统一登记在根 `mise.toml`,子项目继续使用自己的原生命令文件。旧工作区可先通过 `one init hooks` 启用,详见 [`one hk`](/zh/docs/hk/)。 -`one add` 选择技术栈,生成一个可本地开发的项目并登记到 manifest。CI 和部署默认都保持未配置。 +`one add` 可以从技术栈模板生成项目,也可以创建空项目,并登记到工作区 manifest。CI 和部署默认都保持未配置。 有两条入口: @@ -14,6 +14,8 @@ description: 往工作区里加一个模板化项目。 `template-id` 是模板 ID,例如 `nestjs-api` / `nextjs-app` / `ts-library`,不是项目名;项目名由 `--name` 决定。 +创建工作区和添加项目时会准备 mise,并自动信任完全由 One 生成的 `mise.toml`,进入新目录无需再单独执行信任命令。已有自定义配置保留 mise 原有的信任检查。本机没有兼容版本时,One 可能下载托管的 mise 程序;项目工具和依赖仍按需安装。信任失败会保留生成文件并给出恢复命令。 + ## 用法 ```bash @@ -41,6 +43,20 @@ one add [template-id] --name [options] one add nestjs-api --name api --yes ``` +## 创建空项目 + +在已有工作区中,可以先创建目录并登记项目,之后再选择语言或框架: + +```bash +one add empty-app --name web --yes +one add empty-service --name api --yes +one add empty-library --name shared --yes +``` + +三个模板分别创建 `apps/web/`、`services/api/`、`packages/shared/`,仅包含用于 Git 跟踪目录的 `.gitkeep`。它们以 `toolchain: "none"` 登记,不生成 `package.json`、`go.mod`、依赖或启动任务。交互式 `one add` 和 Dashboard 的新建项目选择器也提供这三个选项。 + +如果使用 Node 或 Go,在 `one.manifest.json` 中将项目的 `toolchain` 改为 `node` 或 `go`;Node 项目使用 `packageManager: "pnpm"`,并补齐根工作区的包成员配置,Go 项目则将模块加入根 `go.work`。在 `package.json` / `Taskfile.yml` 中定义任务,或设置项目的 `dev.command`,然后运行 `one init mise` 更新任务配置。使用其他语言时,可以保留 `toolchain: "none"`,在根 `mise.toml` 中自行定义工具和任务,或设置 `dev.command`。配置好命令后再使用 `one dev` / `one build`。 + ## 输出 ```json @@ -126,6 +142,5 @@ one add nestjs-api --name user-api --yes -o json | jq - 检查 `one.manifest.json#projects[]` 确认项目登记 - Agent 文档和本地开发配置会由 `one add` 同步 -- 下一步运行 `one dev ` 开发,使用 `one build ` 构建 -- 如需持续集成,单独运行 `one ci enable ` 生成 GitHub Actions 工作流 -- `one add` 只生成项目和工作区配置;`one dev` 会自动准备工具与应用依赖。JS/TS 在根目录统一安装,Go 按当前模块或 `go.work` 构建图准备依赖。修改 imports 或模块声明需要修复时,显式运行 `one run -- go mod tidy`。 +- 下一步运行 `one dev -p ` 开发,使用 `one build -p ` 构建 +- `one add` 只生成项目和工作区配置;`one dev` 会自动准备工具与应用依赖。JS/TS 在根目录统一安装,Go 按当前模块或 `go.work` 构建图准备依赖。修改 imports 或模块声明需要修复时,显式运行 `one exec -- go mod tidy`。 diff --git a/apps/docs/content/docs/zh/ai-native.md b/apps/docs/content/docs/zh/ai-native.md index 093d5d6..9127f51 100644 --- a/apps/docs/content/docs/zh/ai-native.md +++ b/apps/docs/content/docs/zh/ai-native.md @@ -97,9 +97,7 @@ agent 的处理顺序应该是: ## 规则三:项目说明由团队维护 -新工作区包含简短的根 `AGENTS.md`,要求使用 `one-cli` skill;缺失时运行 `one skills install`。普通创建与 preset 创建使用同一份指引,后续 `one add` 保留团队修改。 -Skill 要求遵循 [One Workspace Convention](https://github.com/1cli-team/one-workspace-convention),并查询当前 CLI 的 help。Agent 还可以读取 `one.manifest.json` 和项目 README。Skill 安装独立于项目创建,详见 [Skills](./skills)。 ## 规则四:配置和凭据有边界 diff --git a/apps/docs/content/docs/zh/build.md b/apps/docs/content/docs/zh/build.md index 46d197a..f0c8f71 100644 --- a/apps/docs/content/docs/zh/build.md +++ b/apps/docs/content/docs/zh/build.md @@ -1,43 +1,19 @@ --- -title: one build -description: 构建全部项目或指定项目。 +title: build 任务 +description: 通过 mise 构建项目及其本地依赖。 --- -`one build` 自动准备工具和应用依赖,再逐个执行项目构建任务。 +`one build` 是 `one run build` 的简写。`build` 和 `test`、`lint` 一样是普通任务名,共用任务解析、参数和帮助。最终执行 mise 中生效的任务图,包括你在 `mise.toml` 中定义的覆盖。 -```bash +```sh one build -one build web -one build apps/web +one build -p web -p api one build -p web --env prod -one build --dry-run -o json +one build -p web --dry-run -o json +one build --cache off --force +one build -p web --ui raw ``` -不指定项目时,构建 manifest 中所有可构建项目;从项目子目录执行也保持这个行为。项目名或工作区相对路径只选择该项目,不自动构建它依赖的其他本地项目。 +Node 从 `package.json` 读取命令,使用 pnpm;Go 从 Taskfile 读取命令。全工作区构建只包含存在 build 的项目,显式选择没有 build 的项目会报错。本地 Node 上游先于下游构建。默认并发为 1,`--concurrency 4` 可并行执行独立任务。 -## 构建命令 - -Dashboard 的项目概览会展示实际构建命令和配置来源。该字段只读;修改项目中的 `package.json` 或 `Taskfile.yml` 后,刷新页面即可看到更新。 - -- Node:读取当前 `package.json`,使用工作区包管理器(pnpm / npm / yarn / bun)执行 build 脚本。 -- Go:使用项目 `Taskfile.yml` 中的 `task build`。Go API 模板生成 `bin/server`;Go 库模板使用 `go build ./...` 编译包。旧的 Go 库可手动给 Taskfile 补上该任务。 - -Go 项目必须有 Taskfile。全量构建跳过没有 build 任务的项目,记录 `no-build-task`;显式选择这类项目时返回 `RUNTIME_TASK_NOT_FOUND`。配置损坏或必要文件缺失会在准备依赖前报错。没有任何构建任务的工作区也会报错。产物位置由项目自己的构建脚本决定。 - -## 执行顺序 - -全量构建串行执行,先构建本地 Node 依赖,再构建使用它们的项目。依赖来源为 dependencies、devDependencies、optionalDependencies,按 package.json 的 name 匹配;file: / link: 依赖按目录匹配。无依赖约束的项目保持 manifest 遍历顺序。重复包名和循环依赖在执行前报错。Go 的包依赖由 Go 工具链处理。 - -首个构建失败后停止,剩余任务记录为 `not_run`,保留失败子进程的退出码。Ctrl+C 停止当前构建及其子进程。日志带项目名前缀。 - -## 工具、依赖与环境变量 - -沿用 `one dev` 的 mise/builtin 自动选择与依赖准备方式,也覆盖没有 dev 命令的库项目。Node 依赖在工作区根目录统一安装一次。每个构建通过 `one run` 使用项目环境变量和 PATH,在项目目录执行。`--env` 指定环境,省略时使用 manifest 默认环境。 - -`--dry-run` 展示排序后的命令、目录和跳过项目,不安装工具或依赖、不读取密钥、不联网、不写文件。 - -## 输出 - -`-o json` / `-o yaml` 预览返回 `one-cli/build-plan/v1`,实际执行返回 `one-cli/build-result/v1`。过程日志写入 stderr,stdout 保持可解析。结果包含每个项目的状态、命令、耗时和退出码。依赖准备失败时返回错误,构建任务保持 `not_run`。 - -自定义命令使用 [`one run`](/zh/docs/run/)。 +Dashboard 展示项目构建命令及其来源;修改对应源文件即可改变命令。跨项目依赖和缓存规则由 mise 配置声明。完整任务管理、自定义缓存、终端模式和 Actions 示例见 [one run](/zh/docs/run/)。 diff --git a/apps/docs/content/docs/zh/ci.md b/apps/docs/content/docs/zh/ci.md deleted file mode 100644 index 282586f..0000000 --- a/apps/docs/content/docs/zh/ci.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: 持续集成 -description: 查看、启用、更新或移除可选的持续集成工作流。 ---- - -持续集成是可选能力。`one create` 和 `one add` 都不会生成工作流文件。当前版本 -支持 GitHub Actions。 - -## 用法 - -```bash -one ci -one ci enable [project] -one ci sync [project] -one ci disable [project] -``` - -`one ci` 是只读命令,会显示每个项目的当前状态和最合适的下一步命令。 - -## 命令 - -| 命令 | 行为 | -|---|---| -| `one ci` | 查看全部项目的持续集成状态 | -| `one ci enable web` | 为 `web` 生成标准工作流 | -| `one ci enable` | 为全部项目启用持续集成 | -| `one ci sync web` | 在已经启用时重新生成 `web` 的工作流 | -| `one ci sync` | 更新所有已经存在的工作流 | -| `one ci disable web` | 确认后删除 `web` 的生成工作流 | -| `one ci disable --yes` | 不询问,删除全部生成工作流 | - -拒绝停用确认或按 Ctrl-C 都属于正常取消:命令以 0 退出,文件保持不变。 - -非交互调用不会把“没有终端”视为同意删除。必须显式传入 `--yes`,否则命令返回 -`CI_DISABLE_CONFIRMATION_REQUIRED`,并保留全部工作流文件。 - -## 文件与工作区状态 - -GitHub Actions 的标准文件按项目写入 `.github/workflows/`。持续集成选择不会写入 -`one.manifest.json`,生成文件本身就是状态。因此 `enable`、`sync`、`disable` 都 -不会修改 manifest。 - -## 自动化 - -```bash -one ci -o json -one ci enable web --provider ci/github-actions -o json -one ci sync --project web -o json -one ci disable web --yes -o json -``` - -稳定 schema 是 `one-cli/ci-status/v1`、`one-cli/ci-enable/v1`、 -`one-cli/ci-sync/v1` 和 `one-cli/ci-disable/v1`。Provider ID 和 JSON 字段不会翻译。 -`--project` 为旧脚本保留,日常帮助优先展示位置参数。 - -## 错误 - -| 错误码 | 处理 | -|---|---| -| `CI_DISABLE_CONFIRMATION_REQUIRED` | 检查目标项目后,带 `--yes` 重新运行 | -| `CI_NOT_ENABLED` | 先运行 `one ci enable `,再重试 sync | -| `CI_PROVIDER_UNKNOWN` | 使用 `error.context.available_providers` 中的 ID | -| `CI_RENDER_FAILED` | 检查错误 context 中的项目和工作流路径 | - -稳定错误信封详见 [错误码](/zh/docs/error-codes/)。 diff --git a/apps/docs/content/docs/zh/cli-overview.md b/apps/docs/content/docs/zh/cli-overview.md index 5a164bd..4aac4ec 100644 --- a/apps/docs/content/docs/zh/cli-overview.md +++ b/apps/docs/content/docs/zh/cli-overview.md @@ -7,15 +7,17 @@ description: One CLI 日常命令和高级入口。 | --- | --- | | `one create` | 创建工作区 | | `one add` | 添加项目 | -| `one dev` | 启动开发 | -| `one build` | 执行项目构建 | | `one env` | 查看和管理项目环境变量 | | `one login` / `one whoami` / `one logout` | 单账号浏览器会话 | | `one env --global` | 发现共享凭据位置与环境 | -| `one run` | 注入变量后执行命令 | +| `one exec` | 注入变量后执行命令 | | `one serve` | 打开 Dashboard | | `one locale` | 本机语言 | | `one init mise` / `one init hooks` | 工作区工具配置 | -| `one ci` / `one templates` / `one skills` | 自动化与资源 | +| `one run` | 列出和执行工作区任务 | +| `one templates` | 查看项目模板 | +| `one hk` | 执行 Git hooks 检查 | + +`dev`、`build`、`test`、`lint` 都是普通工作区任务名。`one <任务名>` 是 `one run <任务名>` 的简写:`one dev` 执行 `one run dev`,`one build` 执行 `one run build`。通用参数与任务发现见[任务管理](/zh/docs/run/)。 通过 `one help --all` 发现完整命令,执行前查看对应 `--help`。Agent 无需在 Dashboard 复制执行命令,可以自主读取帮助、列出目录与变量元数据,再用明确的环境和目录执行任务。详情见[登录与共享凭据](/zh/docs/login/)。 diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md index c8fe8ec..7ed014d 100644 --- a/apps/docs/content/docs/zh/create.md +++ b/apps/docs/content/docs/zh/create.md @@ -18,7 +18,6 @@ one create [dir] [options] | `dir` | 目标目录(位置参数)。传 `.` 在当前目录就地创建(用 `basename(cwd)` 当名字);目标目录必须不存在或为空 | | `-n, --name ` | 工作区名称(默认 `basename(dir)`) | | `-y, --yes` | 非交互模式:使用默认值;必须显式传 `dir` | -| `--env-provider ` | env 后端选择;默认 `dotenv`,需要 Infisical 时显式传 `infisical` | | `-o, --output ` | `json` / `yaml` / `text`(默认按 TTY 检测) | ## 交互模式 @@ -28,13 +27,11 @@ one create [dir] [options] 1. 目标目录(例如 `./my-app`,也可以填 `.` 表示当前目录) 2. 工作区名称(可留空;留空时使用目标目录的 basename) -`one create` 不会在交互模式里询问 deploy / container,也不会再询问是否切换 Infisical。默认 env 后端是 `env/dotenv`;如果要在创建时使用 Infisical,请显式传 `--env-provider infisical`。 脚本、CI、agent 场景用非交互写法: ```bash one create my-app --yes -one create my-app --yes --env-provider infisical ``` ## 默认能力 @@ -45,35 +42,24 @@ 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` 自动生成项目配置 | +| 环境变量 | 未绑定 | 需要时绑定 Infisical,否则继承 shell 环境 | +| 本地开发 | `one dev` | 通过 mise 运行开发任务图 | +| Git 检查 | hk | 生成 `.config/hk.pkl` 并安装本地 Git 启动器 | +| 工具环境 | mise | 自动生成根 `mise.toml`;后续 `one add` 在同一文件登记项目任务 | | Git 检查 | hk | 创建共享检查配置并安装本地提交钩子;后续 `one add` 增量加入语言检查 | -创建和添加项目只生成配置,不下载工具。首次运行时 One 优先使用兼容的系统 mise,否则按需下载并托管;本地没有可用版本时需要联网,正常命令保持不变。工具版本与已有 workspace 的启用方式见 [`one init mise`](/zh/docs/login/#mise-工作区工具配置)。 +创建工作区和添加项目时会准备 mise,并自动信任完全由 One 生成的 `mise.toml`,进入新目录无需再单独执行信任命令。已有自定义配置保留 mise 原有的信任检查。本机没有兼容版本时,One 可能下载托管的 mise 程序;项目工具和依赖仍按需安装。信任失败会保留生成文件并给出恢复命令。工具版本与已有 workspace 的启用方式见 [`one init mise`](/zh/docs/login/#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 init hooks`。 持续集成默认不配置。创建工作区不会写入 `.github/workflows/`;添加项目后如有 -需要,再显式运行 `one ci enable `。 +需要,可以手动配置调用 `one run ci` 的工作流。 -## --env-provider 语义 +## Infisical 绑定 -`--env-provider ` 显式指定 env 后端: - -```bash -one create my-app -y --env-provider infisical -``` - -使用 Infisical 前先通过浏览器登录: - -```bash -one login -``` - -`one create --env-provider infisical` 会尽量自动绑定 / 创建 Infisical project;如果当时登录、网络或权限没准备好,工作区仍会创建成功,首次 `one env set/get/list/pull` 会再尝试一次 lazy auto-bind。 +创建时不访问 Infisical,也不写入 `env` 绑定。需要托管变量时先通过 `one login` 登录,首次 `one env set`、`get` 或 `list` 会初始化绑定;也可在 Dashboard 选择已有项目。未绑定时执行命令使用 shell 环境。 ## 输出 @@ -84,16 +70,14 @@ one login "created_path": "/abs/path/my-app", "created_in_place": false, "package_manager": "", - "secrets_backend": "dotenv", - "ci_enabled": false, + "secrets_backend": "infisical", "dev_enabled": true } ``` `package_manager` 在空工作区或纯 Go 工作区中为空字符串;含 Node 项目的 preset 会返回实际包管理器名称。 -`secrets_backend` 是 env 域 backend 名(`dotenv` / `infisical`);`ci_enabled` -为兼容 wire format 继续保留,默认是 `false`,`dev_enabled` 是 `true`。部署配置会在首次部署时写入。 +`secrets_backend` 表示支持的来源(`infisical`),不代表已经绑定;`dev_enabled` 是 `true`。 ## 示例 @@ -114,7 +98,7 @@ one create my-app --yes ### 切换到 Infisical 作为 secrets 后端 ```bash -one create my-app --yes --env-provider infisical +one create my-app --yes ``` ### 在当前目录就地创建 @@ -130,7 +114,7 @@ one create . --yes one create my-app --yes cd my-app one add nestjs-api --name api --yes -one dev api +one dev -p api ``` ## 错误恢复 @@ -140,11 +124,11 @@ one dev api | `EXISTING_TARGET_NOT_EMPTY` | 换一个空目录,或手动删除目标后重试 | | `INVALID_NAME` | 名字必须匹配 `^[a-zA-Z0-9][a-zA-Z0-9_-]*$`;空格替换为 `-` | | `PROJECT_NAME_REQUIRED` | 非交互模式必须把工作区目录作为位置参数传入 | -| `BACKEND_ID_UNKNOWN` | `--env-provider` 值无效(合法值:dotenv / infisical) | | `WORKSPACE_NESTED_FORBIDDEN` | 拒绝在已有 workspace 里再 create;换目录或用 `one add` | 完整码表:[错误码大全](/zh/docs/error-codes/)。 ## Agent 指引 -普通创建和 preset 创建都会写入简短的根 `AGENTS.md`,要求使用 `one-cli` skill,缺失时运行 `one skills install`。Skill 安装是独立的用户级操作;后续 `one add` 保留这份文件及团队自行补充的内容。 + +新工作区生成精简的中英文 `AGENTS.md`,各用 7 条规则覆盖工作区定位、CLI 帮助、添加项目、任务与验证、依赖、环境变量和自动化错误处理。命令细节通过 `one --help` 按需查询,不内嵌完整命令手册。该文件由团队维护,后续 `one add` 保留修改。 diff --git a/apps/docs/content/docs/zh/dev.md b/apps/docs/content/docs/zh/dev.md index 5005933..5f265d6 100644 --- a/apps/docs/content/docs/zh/dev.md +++ b/apps/docs/content/docs/zh/dev.md @@ -1,26 +1,23 @@ --- -title: one dev -description: 启动全部可开发项目,或只启动一个项目。 +title: dev 任务 +description: 通过 mise 运行开发任务及其前置依赖。 --- -`one dev` 从 manifest 读取每个项目的开发命令,并用 One CLI 内置 supervisor 运行。 +`one dev` 是 `one run dev` 的简写。`dev` 和 `test`、`lint` 一样是普通任务名,共用任务解析、参数和帮助。最终执行 mise 中生效的任务图,包括你在 `mise.toml` 中定义的覆盖。 -启用 mise 的 workspace 会自动在每个项目的 mise 工具环境中运行开发命令,仍然使用 `one dev` / `one dev web`,无需增加 runtime 参数。日志前缀、项目选择和整组服务的停止行为继续由原有 supervisor 负责。mise 的安装与旧项目启用见 [`one init mise`](/zh/docs/login/#mise-工作区工具配置)。 - -## 用法 - -```bash -one dev [project] [--dry-run] +```sh +one dev +one dev -p web -p api +one dev -p apps/web --dry-run -o json +one dev -p web --ui raw +one dev -p web -- --port 4300 ``` -## 参数 +不传 `-p` 时运行根目录的 `dev` 任务,生成的聚合任务包含有 dev 任务的项目。重复 `-p` 可按名称或相对路径选择项目。任务参数放在 `--` 后;传开发参数时选择一个项目。 + +## 命令与覆盖 -| 参数 | 说明 | -|---|---| -| 位置参数 `project` | 只启动一个项目;支持 manifest 里的 `name` 或 `relativeDir` | -| `-p, --project ` | 为旧脚本和 CI 保留的选择参数 | -| `--dry-run` | 只打印将调用的 supervisor 命令,不安装工具或依赖、不访问网络、不写文件 | -| `-o, --output ` | `json` / `yaml` / `text`(默认按 TTY 检测) | +生成的适配任务优先使用 `projects[].dev.command`,未设置时使用项目原生的 `dev` 包脚本或 Taskfile 任务。在根 `mise.toml` 中可以覆盖 `web:dev` 这样的项目任务,也可以替换工作区聚合。自定义 mise 命令使用 mise 的环境;生成的适配任务接收 One 冻结的项目环境。 ## 依赖准备 @@ -29,32 +26,17 @@ one dev [project] [--dry-run] - Node:在工作区根目录统一准备依赖。使用 pnpm 时,先通过 pnpm 自身检查工作区的安装状态;手动 `pnpm install` 后,只要依赖与当前工作区一致,就直接复用。需要安装时执行 `pnpm install --no-frozen-lockfile`,自动同步新增项目或依赖变更。pnpm 10.14 之前的版本沿用 One 的安装缓存。其他包管理器仍使用原有锁文件策略。 - `one build` 保留现有的严格安装策略:已有 pnpm 依赖锁文件时使用 `--frozen-lockfile`。构建发现锁文件过期后,需要先安装并审阅锁文件变更。 - Go:独立模块下载固定构建列表并补充 `go.sum`;存在 `go.work` 时由 Go 按实际包依赖解析本地成员和外部依赖,按需维护 `go.work.sum`。准备过程不自动运行 `go mod tidy` 或 `go work sync`。 -- 所有准备成功后才启动 supervisor。失败保留底层错误和下载缓存,可修复后重试原命令;取消时停止准备进程。 +- 所有准备成功后才启动 mise 任务图。失败保留底层错误和下载缓存,可修复后重试原命令;取消时停止准备进程。 -`go.work.sum` 不替代各模块发布所需的 `go.sum`。模块声明需要修复时,可显式运行 `one run api -- go mod tidy`。`one run` 保持直接执行命令,不自动安装应用依赖。 +`go.work.sum` 不替代各模块发布所需的 `go.sum`。模块声明需要修复时,可显式运行 `one exec api -- go mod tidy`。`one exec` 保持直接执行命令,不自动安装应用依赖。 -## 运行方式 - -内置 supervisor 默认启动全部可开发项目,也可用位置参数只启动一个。 - -```bash -one dev -one dev web -one dev apps/web --dry-run -``` -## 错误恢复 +## 日志、输入与退出 -| 错误码 | 处理 | -|---|---| -| `RUN_COMMAND_NOT_FOUND` | 检查原生工具安装或 mise 配置 | -| `ONE_CLI_ERROR` | 按底层依赖错误修复网络、锁文件或模块声明后重试 | -| `SUBPROJECT_NOT_FOUND` | 使用项目 `name` 或 `relativeDir` | +开发任务默认自动分配足够的并发数量。多服务使用 mise 带前缀的日志;指定 `--concurrency` 时需为所有开发服务留出运行位置。上游有限构建完成后才会启动依赖它的服务。 -完整码表:[错误码大全](/zh/docs/error-codes/)。 +单个服务可使用 `--ui raw` 保留原生终端输入,此时关闭产物缓存。mise 的 raw/interactive 任务独占终端,需要交互的服务请单独运行。多开发服务不支持 raw 输出。原 TUI、交互项目选择器、`--keep-going` 和单服务重启控件已移除。 -## 进一步阅读 +Ctrl+C 或 SIGTERM 会停止本次调用及其子进程。任务失败会停止本次调用并返回子进程退出码。JSON/YAML 使用 `one-cli/task-result/v1`,子进程日志写到 stderr。`--dry-run` 使用静态 `one-cli/task-plan/v1`,不安装工具、不读取密钥、不写配置。 -- [本地开发编排](/zh/tutorials/dev-local/) — 内置 supervisor 的完整流程 -- [`one run`](/zh/docs/run/) — 只给单条命令注入环境变量 -- [manifest](/zh/docs/manifest/) — 项目列表的来源 +通用参数、任务配置、缓存与命令重名规则见 [one run](/zh/docs/run/)。 diff --git a/apps/docs/content/docs/zh/env-vars.md b/apps/docs/content/docs/zh/env-vars.md index bbb24b7..5e28315 100644 --- a/apps/docs/content/docs/zh/env-vars.md +++ b/apps/docs/content/docs/zh/env-vars.md @@ -1,250 +1,92 @@ --- title: one env -description: 多环境环境变量 — set / get / list / pull 子命令的完整参考。 +description: 使用 set、unset、list 管理 Infisical 变量,并在命令执行时直接注入。 --- -`one env` 管 monorepo 多环境变量。两种后端: +Infisical 是 One CLI 唯一管理的环境变量来源。变量在执行命令时拉取并注入子进程,One CLI 不读取、写入或导出 `.env` 文件。 -- **dotenv**(默认):本地文件系统。每个项目持有 `.env` + `.env.` + `.env.local` + `.env..local` 的 overlay。 -- **infisical**:[Infisical](https://infisical.com/)。同一个工作区共享一个 Infisical project,环境(dev / preview / prod / ...)是 project 内分区。 +## 绑定工作区 -完整工作流和心智模型见 [环境变量指南](/zh/tutorials/env-vars/)。 +新工作区没有 Infisical 绑定,无需登录即可运行。先执行 `one login`,首次使用 `one env set` 或 `one env list` 时会初始化绑定。也可以在 Dashboard 的工作区设置中选择已有 Infisical 项目。 -## 环境模型 - -`manifest.environments.names` 是工作区的环境列表(`one create` 默认 `["dev","preview","prod"]`),`manifest.environments.default` 是不传 `--env` 时的回退值(默认 `dev`)。已有 Workspace 里的 `staging` 仍然有效;当 Manifest 只声明 `staging` 而没有 `preview` 时,Dashboard 的“预览环境”绑定会映射到 `staging`。 - -`--env` 解析链: - -```text ---env flag → manifest.environments.default → environments.names[0] -``` - -`one env set FOO bar --env qa` 可以创建新环境。TTY 模式会确认,非 TTY / `--yes` 会直接追加到 `manifest.environments.names`。`get` / `list` / `pull` 是只读语义,未知环境直接报 `ENV_UNKNOWN_ENVIRONMENT`。 - -## 用法 - -```bash -one env set [VALUE] [--env ] [-p ] [--yes] -one env get [--env ] [-p ] --reveal -one env list [--env ] [-p ] -one env pull [--env ] [-p ] [--force] [--dry-run] -``` - -`-p / --project` 接受 manifest 里的项目名或相对路径: - -```bash -one env set FOO=bar -p web -one env set FOO=bar -p apps/web -one env set FOO=bar # cwd 在项目里时自动识别 -one env pull -p api # 只拉 api 项目 -one env pull --env staging # 拉所有项目的 staging 环境变量 -``` - -通用输出 flag 是 `-o / --output`,取值 `json` / `yaml` / `text`。 - -> 当前没有 `one env init` 子命令。Infisical project binding 由 `one create --env-provider infisical` 自动尝试;如果 create 时登录、网络或权限还没准备好,首次 `set/get/list/pull` 会再尝试 lazy auto-bind。 - -机器级 Infisical 凭据通过 [`one login`](/zh/docs/login/) 配,不进入 manifest。 - -## 交互模式 - -直接运行 `one env` 会显示当前来源、默认/可用环境、当前作用域和常用命令。`one env set` 在 TTY 下提供安全流程: - -- `one env set KEY` 使用隐藏输入读取值。 -- 在工作区根执行时,询问变量是工作区共享还是属于某个项目。 -- 写入一个不在 `manifest.environments.names` 里的新环境时,会确认是否把该环境加入 manifest。 -- 覆盖已有不同值时,会确认是否覆盖。 - -拒绝或取消会正常退出,不显示错误样式。脚本和 CI 显式传值,并用 `--yes` 确认覆盖或新环境。 - -## dotenv overlay - -dotenv 后端读取顺序: - -```text -/.env -/.env. -/.env.local -/.env..local -``` - -后面的文件覆盖前面的文件。`one env set` 写 `.env.`;`.local` 文件只读,由开发者自己维护。 - -`one create` 默认写入 `.gitignore`: - -```text -.env -.env.* -!.env.example -``` - -## set - -写入单个 key。两种参数形式都支持: - -```bash -one env set DATABASE_URL "postgres://localhost/dev" --env dev -p api -one env set JWT_SECRET=dev-only-secret --env dev -p api --yes -``` - -输出 schema:`one-cli/env-set/v1` +绑定保存在 `one.manifest.json` 顶层的 `env`: ```json { - "schema": "one-cli/env-set/v1", - "env": "dev", - "key": "DATABASE_URL", - "action": "created" + "env": { + "siteUrl": "https://app.infisical.com", + "projectId": "your-project-id", + "projectName": "my-workspace", + "rootPath": "/" + }, + "environments": { + "names": ["dev", "preview", "prod"], + "default": "dev" + } } ``` -`action` 可能是 `created` / `updated` / `unchanged`。已有不同值时需要 `--yes` 确认覆盖。 +Manifest 只记录绑定信息、目录路径和变量名。值保存在 Infisical,登录使用系统 keyring 中的浏览器会话。 -## get - -读取单个 key: +## 命令 ```bash -one env get DATABASE_URL --env dev -p api --reveal -DB_URL=$(one env get DATABASE_URL --env dev -p api -o json | jq -r .value) --reveal +one env +one env set DATABASE_URL -p api +one env set API_URL=https://example.com -p web --env dev --yes +one env list -p web --env dev ``` -输出 schema:`one-cli/env-get/v1` +终端中的 `set KEY` 会隐藏输入值;脚本显式传入值。`--yes` 确认覆盖已有值或新增环境名。`list` 只返回变量名。CLI 不提供明文读取命令;通过 `one exec` 将值注入子进程。`-o json` 或 `-o yaml` 输出结构化结果,保留 `one-cli/env-set/v1`、`one-cli/env-list/v1` 协议。 -```json -{ - "schema": "one-cli/env-get/v1", - "env": "dev", - "key": "DATABASE_URL", - "value": "postgres://..." -} -``` +`-p / --project` 接受项目名或工作区相对路径。不传时按当前目录推断项目;在工作区根目录操作共享变量,交互式 `set` 会提供作用域选择。 -## list +## 环境与目录 -列出 key 名,不显示值: +`environments.names` 声明可用环境,默认使用 `environments.default`,缺省时取第一个名称。新工作区声明 `dev`、`preview`、`prod`,默认 `dev`。`--env` 可覆盖选择。 -```bash -one env list --env dev -p api -``` +`set` 可以经确认登记新环境。`list` 对未声明名称返回 `ENV_UNKNOWN_ENVIRONMENT`;这些命令仍可能初始化尚未存在的 Infisical 绑定。 -输出 schema:`one-cli/env-list/v1` +项目默认使用 `relativeDir` 对应的 Infisical 目录,也可通过 `projects[].env` 覆盖: ```json { - "schema": "one-cli/env-list/v1", - "env": "dev", - "keys": ["DATABASE_URL", "JWT_SECRET"] + "name": "api", + "relativeDir": "services/api", + "env": { + "path": "/teams/payments/api", + "inherits": true, + "keys": ["DATABASE_URL"] + } } ``` -## pull +默认启用继承,变量按根目录、祖先目录、项目目录合并,越靠近项目的值优先。`inherits: false` 只读项目目录;`disabled: true` 停用该项目的 Infisical 注入。 -把 Infisical 的环境变量拉到本地 `.env`: +## 带变量执行 ```bash -one env pull --env dev -one env pull --env dev -p api --dry-run -one env pull --env dev --force -``` - -默认不传 `-p` 时遍历 `manifest.projects[]`。每个项目按 `projects[].domains.env.path` 或 `relativeDir` 映射到 Infisical folder,并把 root → ancestors → self 的继承链合并后写入该项目目录的 `.env`。 - -输出 schema:`one-cli/env-pull/v1` - -```json -{ - "schema": "one-cli/env-pull/v1", - "env": "dev", - "dry_run": false, - "written_count": 1, - "skipped_count": 0, - "per_subproject": [ - { - "name": "api", - "relative_dir": "services/api", - "infisical_path": "/services/api", - "env_file_path": "/abs/.../services/api/.env", - "status": "written", - "keys_written": ["DATABASE_URL", "JWT_SECRET"] - } - ] -} -``` - -## manifest 配置 - -Workspace 级 env 后端写在 `one.manifest.json#domains.env`,环境列表写在顶层 `environments`: - -```json -{ - "environments": { - "names": ["dev", "staging", "prod"], - "default": "dev" - }, - "domains": { - "env": { - "kind": "infisical", - "config": { - "siteUrl": "https://app.infisical.com", - "projectId": "...", - "projectName": "my-workspace", - "rootPath": "/" - } - } - } -} +one exec -p api --env dev -- go run ./cmd/server +one run dev --env dev ``` -项目级 path 覆盖写在 `projects[].domains.env`: +配置 `env` 后,生成的任务适配器与 `one exec` 会拉取 Infisical 变量,并覆盖同名 shell 变量。未绑定或项目停用注入时,继承 shell 环境。认证或拉取失败会停止执行,不回退到本地文件。自定义 mise 任务使用 mise 的环境。One 会遮盖 stdout、stderr 和任务缓存回放中的已知注入值,覆盖多行值及其 JSON 转义形式。 -```json -{ - "projects": [ - { - "name": "charge", - "relativeDir": "services/charge", - "domains": { - "env": { - "path": "/teams/payments/charge", - "inherits": true - } - } - } - ] -} -``` - -变量值不进入 Manifest;Manifest 记录项目 ID、实例地址、目录和 key 名。认证使用系统钥匙串中的单一浏览器会话。 - -## 凭据安全 +## 全局共享凭据 -通过 `one login` 登录,令牌只保存在系统钥匙串,不落入项目或普通配置文件。 +共享凭据独立于工作区。通过 `one env bind --global` 选择存储项目,使用 `one env list --global --env dev --path /` 查看名称,使用 `one exec --global --env dev --path /folder -- command` 注入明确作用域。详见[登录与共享凭据](/zh/docs/login/)。 -## 错误恢复 +## 常见错误 -| 错误码 | 处理 | +| 错误码 | 恢复方法 | |---|---| -| `INFISICAL_NOT_CONFIGURED` | 确认工作区用了 `--env-provider infisical`,并已通过 `one login` 登录 | -| `INFISICAL_AUTH_MISSING` | 重新跑 `one login` | -| `INFISICAL_AUTH_FAILED` | Infisical 后台重新生成 client secret | -| `INFISICAL_PROJECT_NAME_TAKEN` | 修改 `domains.env.config.projectName` 后重跑 env 命令触发 lazy bind | -| `INFISICAL_PROJECT_CREATE_FORBIDDEN` | 给 machine identity 加 admin 角色,或手动建项目后填 `domains.env.config.projectId` | -| `ENV_PULL_CONFLICT` | 本地 `.env` 已存在且不同;确认后加 `--force` | -| `ENV_KEY_NOT_FOUND` | 核对 path / env / 拼写 | -| `ENV_INVALID_KEY` | KEY 必须匹配 `^[A-Za-z_][A-Za-z0-9_]*$` | -| `ENV_SET_OVERWRITE_REQUIRED` | 已存在不同值;加 `--yes` 确认 | -| `ENV_UNKNOWN_ENVIRONMENT` | `set` 可创建;读命令需要先在 `manifest.environments.names` 声明 | - -完整码表:[错误码大全](/zh/docs/error-codes/)。 - -## 进一步阅读 - -- [环境变量指南](/zh/tutorials/env-vars/) — 心智模型 + 完整工作流 -- [`one create`](/zh/docs/create/) — 起骨架时用 `--env-provider infisical` 接 Infisical - - -## 共享凭据 - -共享凭据独立于工作区。使用 `one env bind --global` 选择存放位置,`one env list --global --env dev --path /` 浏览元数据,`one run --global --env dev --path /folder -- command` 注入明确范围的变量。完整的命令和安全边界见[登录与共享凭据](/zh/docs/login/)。 +| `INFISICAL_NOT_CONFIGURED` | 在 Dashboard 绑定 Infisical 项目,或登录后通过 env 命令初始化 | +| `INFISICAL_AUTH_MISSING` / `INFISICAL_AUTH_FAILED` | 执行 `one login` 并检查绑定项目的访问权限 | +| `INFISICAL_PROJECT_NAME_TAKEN` | 设置不同的 `env.projectName`,或显式绑定已有项目 | +| `INFISICAL_PROJECT_CREATE_FORBIDDEN` | 在 Dashboard 选择已有且可访问的项目 | +| `ENV_KEY_NOT_FOUND` | 检查变量名、环境和目录 | +| `ENV_INVALID_KEY` | 变量名须匹配 `^[A-Za-z_][A-Za-z0-9_]*$` | +| `ENV_SET_OVERWRITE_REQUIRED` | 确认替换后添加 `--yes` | +| `ENV_UNKNOWN_ENVIRONMENT` | 通过 `set` 登记新环境,或选择已有名称 | + +旧工作区参阅 [Manifest 迁移](/zh/docs/manifest/),初次配置参阅[操作教程](/zh/tutorials/env-vars/)。 diff --git a/apps/docs/content/docs/zh/error-codes.md b/apps/docs/content/docs/zh/error-codes.md index 6f3f985..7938d13 100644 --- a/apps/docs/content/docs/zh/error-codes.md +++ b/apps/docs/content/docs/zh/error-codes.md @@ -40,18 +40,6 @@ import { Callout } from "fumadocs-ui/components/callout"; 下面按命令域分组列出所有 code。 -## Agent skill 安装 - -内置 one-cli skill 的目标选择与用户级目录安装错误。 - -### `SKILLS_INSTALL_FAILED` - -The bundled one-cli skill could not be installed into the selected agents. - -**Remediation**: - -- `inspect-skill-install` — Check the target agent and directory permissions. Completed targets are listed in context.installed_to; retrying is safe.
运行:`one skills install --help` - ## 通用 / 生命周期 命令本身的失败、用户取消、内部序列化错误。 @@ -227,30 +215,6 @@ Workspace 后置同步失败:写入 manifest 后某个后端 sync 回滚或失 Profile 解析、CI 产物生成和本地开发过程中的问题。 -### `CI_DISABLE_CONFIRMATION_REQUIRED` - -A non-interactive CI disable requires explicit --yes confirmation. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `CI_NOT_ENABLED` - -The selected project does not have a generated CI workflow. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `CI_PROVIDER_UNKNOWN` - -The requested CI provider is not implemented by this build. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `CI_RENDER_FAILED` - -The selected CI provider returned an error while rendering the workflow. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - ### `LOCAL_ORCH_PORT_CONFLICT` Two projects requested the same dev port and the dev runner could not auto-allocate a free one. @@ -269,13 +233,13 @@ The release-flow backend's expected toolchain or repo state does not match the w ### `ENV_BACKEND_INVALID` -env switch 的 不合法,必须是 dotenv 或 infisical。 +The workspace Infisical binding is missing or invalid. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 ### `ENV_BACKEND_UNCHANGED` -工作区已经在使用目标 backend,无需切换。 +Reserved error code from the retired local environment workflow. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 @@ -299,20 +263,15 @@ Requested env var key does not exist at the given Infisical path/environment. ### `ENV_MIGRATE_CONFLICT` -目标 backend 已有同名 key 但值不一致;为防止误覆盖,默认拒绝。 - -**Remediation**: +Reserved error code from the retired local environment workflow. -- `overwrite` — 确认要覆盖,加 --overwrite 重跑
运行:`one env switch infisical --overwrite` *(destructive)* -- `skip-sync` — 或只切 manifest,不做数据迁移
运行:`one env switch infisical --no-sync` +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 ### `ENV_MIGRATE_PARTIAL` -部分 key 同步失败;manifest 已切换,但未完成的 key 仍只在原 backend。 +Reserved error code from the retired local environment workflow. -**Remediation**: - -- `retry` — 检查报错原因(网络 / 权限),修复后再跑同步:one env switch infisical(manifest 已切,等价 sync-only) +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 ### `ENV_PROFILE_NOT_FOUND` @@ -322,11 +281,9 @@ manifest.environments[] was requested by a backend but is missing or empty. ### `ENV_PULL_CONFLICT` -Existing on-disk .env differs from the values pulled from Infisical. +Reserved error code from the retired local environment workflow. -**Remediation**: - -- `force-overwrite` — 覆盖本地 .env(destructive)
运行:`one env pull --env --force` *(destructive)* +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 ### `ENV_SET_KEY_REQUIRED` @@ -355,7 +312,7 @@ Non-interactive env set called without . **Remediation**: - `use-existing-env` — 查看 one.manifest.json#environments.names 中已声明的环境,或改用 --env 指定其中一个 -- `create-via-set` — 在 dotenv 后端,用 set 隐式创建:one env set --env +- `create-via-set` — 先在 Infisical 创建环境,再将名称登记到 manifest.environments.names - `register-env` — 在 Infisical 后端,先在 UI 创建环境,再把名称加入 one.manifest.json#environments.names ## Infisical 后端 @@ -420,7 +377,7 @@ Infisical 项目名已被占用;auto-bind 会自动加随机后缀重试,但 **Remediation**: -- `use-explicit-name` — 在 one.manifest.json#domains.env.config.projectName 写一个不冲突的项目名后重试 env 命令 +- `use-explicit-name` — 在 one.manifest.json#env.projectName 写一个不冲突的项目名后重试 env 命令 ### `INFISICAL_PROJECT_NOT_FOUND` @@ -452,19 +409,17 @@ Backend's Invoke method returned an error. ### `BACKEND_NOT_ENABLED` -A domain command was invoked in a workspace where that domain is not configured. +The requested environment backend is not configured. **Remediation**: -- `configure-domain` — 在 one.manifest.json 的 domains 块中配置该域(domains.env.kind / projects[].domains.container 等),或选用声明它的模板再 one add +- `configure-domain` — Configure the environment backend with one env bind. ### `BACKEND_VERB_NOT_SUPPORTED` -The active backend in this domain does not implement the requested verb (e.g. `one env pull` against the dotenv backend). - -**Remediation**: +The requested environment operation is not supported. -- `switch-backend` — 切换到支持该 verb 的同 domain backend(例如 env 域改用 infisical) +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 ### `DEPENDENCIES_NOT_INSTALLED` @@ -474,7 +429,7 @@ Node dependencies required for local development are not installed. ### `DOMAIN_INVALID` -Domain name is not one of the recognised domains (container / deploy / dev / ci / env). +The requested configuration domain is not supported. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 @@ -494,7 +449,7 @@ Domain is recognised but this build has no backend implementation for it. ### `DOMAIN_REQUIRED` -A domain (container / deploy / dev / ci / env) is required but its section is missing in one.manifest.json. +The environment configuration is missing in one.manifest.json. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 @@ -577,29 +532,26 @@ The project does not provide the requested runtime task. ### `RUN_COMMAND_NOT_FOUND` -one run could not locate the requested executable on PATH. +one exec could not locate the requested executable on PATH. **Remediation**: - `check-spelling` — 确认命令名拼写正确 -- `use-package-runner` — 对于 npm script,使用包管理器调用
运行:`one run -- npm run