diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index cf45d993..00000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/sh -set -eu - -repo_root=$(git rev-parse --show-toplevel) -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 mise run check." >&2 - if [ -n "$unstaged" ]; then - printf '%s\n%s\n' "Unstaged files:" "$unstaged" >&2 - fi - if [ -n "$untracked" ]; then - printf '%s\n%s\n' "Untracked files:" "$untracked" >&2 - fi - printf '%s\n' "Stage the intended files (or stash unrelated work), then commit again." >&2 - exit 1 -fi - -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 - -# Tests create temporary repositories; do not pass this hook's Git context to them. -# 测试会创建临时仓库,不向它们传递提交钩子的 Git 仓库环境。 -for git_env_name in $(git rev-parse --local-env-vars); do - unset "$git_env_name" -done - -printf '%s\n' "pre-commit: running the PR gate (mise run check)" -exec mise run check diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index 039b83d9..00000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh -set -eu - -repo_root=$(git rev-parse --show-toplevel) -cd "$repo_root" - -changes=$(git status --porcelain --untracked-files=normal --ignore-submodules) -if [ -n "$changes" ]; then - printf '%s\n' "pre-push: commit or stash local changes so checks run against the committed snapshot." >&2 - printf '%s\n' "$changes" >&2 - exit 1 -fi - -if ! command -v mise >/dev/null 2>&1; then - printf '%s\n' "pre-push: mise is required; install it before pushing." >&2 - exit 1 -fi - -# Tests create temporary repositories; do not pass the hook's Git context. -# 测试会创建临时仓库,不向它们传递推送钩子的 Git 仓库环境。 -for git_env_name in $(git rev-parse --local-env-vars); do - unset "$git_env_name" -done - -printf '%s\n' "pre-push: running the PR gate, Go race tests, and template builds" -exec mise run --jobs 1 pre-push diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index b3f82e9c..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,227 +0,0 @@ -# PR-time test gate with native Linux, Windows, macOS, and Go race coverage. -# -# 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 `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 -# ruleset requires lint, test, test-windows, test-macos, and test-race before merging. -# -# Both jobs need Node + pnpm because sync-web runs `pnpm install` + -# `vite build` against apps/dashboard/. -# -# Runners: -# - ubuntu-latest for the main PR checks -# - windows-latest for native Windows build and test coverage -# - macos-latest for native macOS build and test coverage - -name: Code Quality and Tests - -on: - pull_request: - push: - branches: [master] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -env: - MISE_TASK_CACHE: "off" - # 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 - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run static gate - # 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 mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run test gate - # Avoid running Go builds/tests alongside jsdom on the same runner. - run: mise run --jobs 1 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 mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - shell: pwsh - run: | - $env:GOWORK = "off" - go -C packages/kernel mod download - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - go -C packages/cli mod download - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Run native Windows PR gate - run: mise run --jobs 1 check - - test-race: - # Catch Go data races before merging, alongside the other required jobs. - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run Go race tests - # test:go builds the CLI for E2E coverage; Dashboard tests run in test. - run: mise run test:go - - test-macos: - # Run before merging so macOS path and process regressions block PRs. - runs-on: macos-latest - steps: - - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run test gate - run: mise run --jobs 1 check:test diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml deleted file mode 100644 index c5b4a70b..00000000 --- a/.github/workflows/cli.yml +++ /dev/null @@ -1,514 +0,0 @@ -# Build and publish a One CLI release with an automatic semantic-version bump. -# No version files or release branches are created on master. - -name: Build and Release -run-name: Release (${{ inputs.bump_type }}) - -on: - workflow_dispatch: - inputs: - bump_type: - description: "Semantic-version component to increment" - required: true - type: choice - default: patch - options: - - patch - - minor - - major - -concurrency: - group: build-and-release - cancel-in-progress: false - -permissions: - contents: write - -env: - MISE_TASK_CACHE: "off" - GOPROXY: https://proxy.golang.org,direct - GOTOOLCHAIN: local - -jobs: - release: - runs-on: ubuntu-latest - timeout-minutes: 120 - steps: - - name: Check out dispatch commit - uses: actions/checkout@v7 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Validate master - run: | - set -euo pipefail - if [[ "$GITHUB_REF" != "refs/heads/master" ]]; then - echo "Run this workflow from the master branch." >&2 - exit 1 - fi - - git fetch origin master --tags - if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/master)" ]]; then - echo "master advanced after this run was dispatched; start a fresh run." >&2 - exit 1 - fi - - - name: Calculate release version - id: version - env: - BUMP_TYPE: ${{ inputs.bump_type }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - stable_tag_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' - latest_tag="" - while IFS= read -r candidate; do - if [[ "$candidate" =~ $stable_tag_re ]]; then - latest_tag="$candidate" - break - fi - done < <(git tag --list 'v*' --sort=-version:refname) - - expected_assets="$(printf '%s\n' \ - checksums.txt \ - one-cli_darwin_amd64.tar.gz \ - one-cli_darwin_arm64.tar.gz \ - one-cli_linux_amd64.tar.gz \ - one-cli_linux_arm64.tar.gz \ - one-cli_windows_amd64.zip | sort)" - mode="bump" - latest_tag_object="" - decision="bump $BUMP_TYPE from no tag" - - if [[ -n "$latest_tag" ]]; then - latest_sha="$(git rev-list -n 1 "$latest_tag")" - latest_tag_object="$(git rev-parse "$latest_tag")" - latest_on_master=false - git merge-base --is-ancestor "$latest_sha" origin/master && latest_on_master=true - - releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" - latest_release="$(jq -c --arg tag "$latest_tag" \ - '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" - - if [[ -z "$latest_release" ]]; then - if [[ "$latest_on_master" == "true" ]]; then - mode="resume" - decision="resume $latest_tag (release missing)" - else - echo "Highest stable tag $latest_tag is off master and has no release; clean it up first." >&2 - exit 1 - fi - elif [[ "$(jq -r '.prerelease' <<< "$latest_release")" == "true" ]]; then - echo "Stable tag $latest_tag is marked as a prerelease; resolve that state first." >&2 - exit 1 - elif [[ "$(jq -r '.draft' <<< "$latest_release")" == "true" ]]; then - if [[ "$latest_on_master" == "true" ]]; then - mode="resume" - decision="resume draft $latest_tag" - else - decision="bump $BUMP_TYPE from off-master draft $latest_tag" - fi - else - actual_assets="$(jq -r '.assets[].name' <<< "$latest_release" | sort)" - if [[ "$actual_assets" != "$expected_assets" ]]; then - echo "Published release $latest_tag has an incomplete or unexpected asset set." >&2 - diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") || true - exit 1 - fi - if [[ "$latest_on_master" == "true" && "$latest_sha" == "$(git rev-parse HEAD)" ]]; then - mode="idempotent" - decision="current commit already published as $latest_tag" - else - decision="bump $BUMP_TYPE from $latest_tag" - fi - fi - fi - - if [[ "$mode" == "resume" || "$mode" == "idempotent" ]]; then - release_version="${latest_tag#v}" - else - base_version="${latest_tag#v}" - [[ -n "$base_version" ]] || base_version="0.0.0" - IFS=. read -r major minor patch <<< "$base_version" - case "$BUMP_TYPE" in - patch) patch=$((patch + 1)) ;; - minor) minor=$((minor + 1)); patch=0 ;; - major) major=$((major + 1)); minor=0; patch=0 ;; - *) echo "Unsupported bump type: $BUMP_TYPE" >&2; exit 1 ;; - esac - release_version="${major}.${minor}.${patch}" - fi - - echo "version=$release_version" >> "$GITHUB_OUTPUT" - echo "base_tag=$latest_tag" >> "$GITHUB_OUTPUT" - echo "base_tag_object=$latest_tag_object" >> "$GITHUB_OUTPUT" - echo "mode=$mode" >> "$GITHUB_OUTPUT" - echo "RELEASE_VERSION=$release_version" >> "$GITHUB_ENV" - { - echo "### Calculated version" - echo "- Version: $release_version" - echo "- Mode: $mode" - echo "- Decision: $decision" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Resolve release state - id: release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag="v$RELEASE_VERSION" - source_sha="$(git rev-parse HEAD)" - tag_exists=false - already_published=false - expected_assets="$(printf '%s\n' \ - checksums.txt \ - one-cli_darwin_amd64.tar.gz \ - one-cli_darwin_arm64.tar.gz \ - one-cli_linux_amd64.tar.gz \ - one-cli_linux_arm64.tar.gz \ - one-cli_windows_amd64.zip | sort)" - releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" - target_release="$(jq -c --arg tag "$tag" \ - '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" - - if git show-ref --verify --quiet "refs/tags/$tag"; then - tag_exists=true - source_sha="$(git rev-list -n 1 "$tag")" - if ! git merge-base --is-ancestor "$source_sha" origin/master; then - echo "Tag $tag does not point to a commit on master." >&2 - exit 1 - fi - - if [[ -n "$target_release" ]]; then - if [[ "$(jq -r '.prerelease' <<< "$target_release")" == "true" ]]; then - echo "Stable tag $tag is marked as a prerelease." >&2 - exit 1 - fi - if [[ "$(jq -r '.draft' <<< "$target_release")" == "false" ]]; then - actual_assets="$(jq -r '.assets[].name' <<< "$target_release" | sort)" - if [[ "$actual_assets" != "$expected_assets" ]]; then - echo "Published release $tag has an incomplete or unexpected asset set." >&2 - diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") || true - exit 1 - fi - already_published=true - fi - fi - else - if [[ -n "$target_release" ]]; then - echo "Release $tag exists without a matching Git tag." >&2 - exit 1 - fi - - existing_stable_tags="$(git tag --points-at "$source_sha" | sed -n -E '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/p')" - if [[ -n "$existing_stable_tags" ]]; then - echo "This commit already has a stable tag: $existing_stable_tags" >&2 - exit 1 - fi - - latest_published_tag="" - while IFS= read -r candidate; do - [[ "$candidate" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || continue - candidate_release="$(jq -c --arg tag "$candidate" \ - '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" - if [[ -n "$candidate_release" ]]; then - if [[ "$(jq -r '[.draft, .prerelease] | @tsv' <<< "$candidate_release")" == $'false\tfalse' ]]; then - latest_published_tag="$candidate" - break - fi - fi - done < <(git tag --list 'v*' --sort=-version:refname) - - if [[ -n "$latest_published_tag" ]]; then - latest_version="${latest_published_tag#v}" - highest="$(printf '%s\n%s\n' "$latest_version" "$RELEASE_VERSION" | sort -V | tail -1)" - if [[ "$highest" != "$RELEASE_VERSION" || "$latest_version" == "$RELEASE_VERSION" ]]; then - echo "version must be newer than the latest published release $latest_published_tag." >&2 - exit 1 - fi - fi - fi - - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "sha=$source_sha" >> "$GITHUB_OUTPUT" - echo "tag_exists=$tag_exists" >> "$GITHUB_OUTPUT" - echo "already_published=$already_published" >> "$GITHUB_OUTPUT" - - { - echo "### Release state" - echo "- Version: $RELEASE_VERSION" - echo "- Tag: $tag" - echo "- Commit: $source_sha" - echo "- Existing tag: $tag_exists" - echo "- Already published: $already_published" - } >> "$GITHUB_STEP_SUMMARY" - - name: Preserve release configuration - if: steps.release.outputs.already_published != 'true' - run: cp .goreleaser.yaml "$RUNNER_TEMP/one-cli-goreleaser.yaml" - - - name: Check out release commit - if: steps.release.outputs.already_published != 'true' - env: - RELEASE_SHA: ${{ steps.release.outputs.sha }} - run: git checkout --detach "$RELEASE_SHA" - - - name: Set up mise - if: steps.release.outputs.already_published != 'true' - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules without mutating the workspace - if: steps.release.outputs.already_published != 'true' - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - if: steps.release.outputs.already_published != 'true' - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run complete release gate - if: steps.release.outputs.already_published != 'true' - # The release gate combines jobs that PR CI runs on separate runners. - # Keep cold Go builds/vet/race tests from starving jsdom interactions. - run: mise run --jobs 1 pre-push - - - name: Verify release tree is clean - if: steps.release.outputs.already_published != 'true' - run: | - set -euo pipefail - git diff --exit-code - if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then - git status --short - echo "Release checkout has tracked changes." >&2 - exit 1 - fi - - - name: Create local release tag - if: steps.release.outputs.already_published != 'true' - env: - TAG: ${{ steps.release.outputs.tag }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - TAG_EXISTS: ${{ steps.release.outputs.tag_exists }} - run: | - set -euo pipefail - if [[ "$TAG_EXISTS" == "true" ]]; then - actual_sha="$(git rev-list -n 1 "$TAG")" - if [[ "$actual_sha" != "$RELEASE_SHA" ]]; then - echo "Tag $TAG points to $actual_sha, expected $RELEASE_SHA." >&2 - exit 1 - fi - else - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" "$RELEASE_SHA" -m "$TAG" - fi - - - name: Run GoReleaser preflight build - if: steps.release.outputs.already_published != 'true' - uses: goreleaser/goreleaser-action@v7 - with: - version: v2.18.0 - args: release --config ${{ runner.temp }}/one-cli-goreleaser.yaml --skip=publish --clean - env: - GORELEASER_CURRENT_TAG: ${{ steps.release.outputs.tag }} - - - name: Verify packaged CLI version - if: steps.release.outputs.already_published != 'true' - run: | - set -euo pipefail - archive="dist/one-cli_linux_amd64.tar.gz" - extract_dir="$RUNNER_TEMP/one-cli-version-check" - test -f "$archive" - mkdir -p "$extract_dir" - tar -xzf "$archive" -C "$extract_dir" - actual_version="$("$extract_dir/one" --version | tr -d '[:space:]')" - if [[ "$actual_version" != "$RELEASE_VERSION" ]]; then - echo "Packaged CLI reports $actual_version, expected $RELEASE_VERSION." >&2 - exit 1 - fi - - - name: Revalidate release state - if: steps.release.outputs.already_published != 'true' - env: - BASE_TAG: ${{ steps.version.outputs.base_tag }} - BASE_TAG_OBJECT: ${{ steps.version.outputs.base_tag_object }} - GH_TOKEN: ${{ github.token }} - MODE: ${{ steps.version.outputs.mode }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - remote_latest_tag="$(git ls-remote --refs --tags origin 'refs/tags/v*' | - awk '{sub("refs/tags/", "", $2); print $2}' | - sed -n -E '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/p' | - sort -V | tail -n 1)" - if [[ "$remote_latest_tag" != "$BASE_TAG" ]]; then - echo "Highest stable tag changed from $BASE_TAG to $remote_latest_tag; restart the release." >&2 - exit 1 - fi - if [[ -n "$BASE_TAG" ]]; then - remote_base_object="$(git ls-remote --refs --tags origin "refs/tags/$BASE_TAG" | awk 'NR == 1 {print $1}')" - if [[ "$remote_base_object" != "$BASE_TAG_OBJECT" ]]; then - echo "Base tag $BASE_TAG moved during this run; restart the release." >&2 - exit 1 - fi - fi - - releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" - target_release="$(jq -c --arg tag "$TAG" \ - '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" - case "$MODE" in - bump) - current_master="$(git ls-remote origin 'refs/heads/master' | awk 'NR == 1 {print $1}')" - if [[ "$current_master" != "$RELEASE_SHA" ]]; then - echo "master advanced to $current_master after validation; restart the release." >&2 - exit 1 - fi - if git ls-remote --exit-code --refs --tags origin "refs/tags/$TAG" >/dev/null 2>&1 || - [[ -n "$target_release" ]]; then - echo "Tag or release $TAG appeared during this run; restart to validate it." >&2 - exit 1 - fi - ;; - resume) - if [[ -n "$target_release" ]]; then - if [[ "$(jq -r '.prerelease' <<< "$target_release")" == "true" || - "$(jq -r '.draft' <<< "$target_release")" != "true" ]]; then - echo "Release $TAG changed while validation was running; restart the release." >&2 - exit 1 - fi - fi - ;; - *) - echo "Unexpected release mode: $MODE" >&2 - exit 1 - ;; - esac - - - name: Push release tag - if: steps.release.outputs.already_published != 'true' && steps.release.outputs.tag_exists != 'true' - env: - BASE_TAG: ${{ steps.version.outputs.base_tag }} - BASE_TAG_OBJECT: ${{ steps.version.outputs.base_tag_object }} - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - run: | - set -euo pipefail - git fetch origin master --tags - current_master="$(git rev-parse origin/master)" - if [[ "$current_master" != "$RELEASE_SHA" ]]; then - echo "master advanced to $current_master after validation; restart the release." >&2 - exit 1 - fi - - remote_latest_tag="$(git ls-remote --refs --tags origin 'refs/tags/v*' | - awk '{sub("refs/tags/", "", $2); print $2}' | - sed -n -E '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/p' | - sort -V | tail -n 1)" - if [[ "$remote_latest_tag" != "$BASE_TAG" ]]; then - echo "Highest stable tag changed from $BASE_TAG to $remote_latest_tag; restart the release." >&2 - exit 1 - fi - if [[ -n "$BASE_TAG" ]]; then - remote_base_object="$(git ls-remote --refs --tags origin "refs/tags/$BASE_TAG" | awk 'NR == 1 {print $1}')" - if [[ "$remote_base_object" != "$BASE_TAG_OBJECT" ]]; then - echo "Base tag $BASE_TAG moved during this run; restart the release." >&2 - exit 1 - fi - fi - - if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "Remote tag $TAG appeared during this run; restart to validate it." >&2 - exit 1 - fi - gh auth setup-git - git push origin "refs/tags/$TAG" - - - name: Run GoReleaser - if: steps.release.outputs.already_published != 'true' - uses: goreleaser/goreleaser-action@v7 - with: - version: v2.18.0 - args: release --config ${{ runner.temp }}/one-cli-goreleaser.yaml --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GORELEASER_CURRENT_TAG: ${{ steps.release.outputs.tag }} - - - name: Verify draft release assets - if: steps.release.outputs.already_published != 'true' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - is_draft="$(gh release view "$TAG" --json isDraft --jq '.isDraft')" - if [[ "$is_draft" != "true" ]]; then - echo "Release $TAG must remain a draft until its assets are verified." >&2 - exit 1 - fi - - expected_assets="$(printf '%s\n' \ - checksums.txt \ - one-cli_darwin_amd64.tar.gz \ - one-cli_darwin_arm64.tar.gz \ - one-cli_linux_amd64.tar.gz \ - one-cli_linux_arm64.tar.gz \ - one-cli_windows_amd64.zip | sort)" - actual_assets="$(gh release view "$TAG" --json assets --jq '.assets[].name' | sort)" - if [[ "$actual_assets" != "$expected_assets" ]]; then - echo "Draft release $TAG has an incomplete or unexpected asset set." >&2 - diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") || true - exit 1 - fi - - - name: Publish GitHub release - if: steps.release.outputs.already_published != 'true' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - gh release edit "$TAG" --draft=false --latest - is_draft="$(gh release view "$TAG" --json isDraft --jq '.isDraft')" - if [[ "$is_draft" != "false" ]]; then - echo "Release $TAG is still a draft after publishing." >&2 - exit 1 - fi - - - name: Summarize release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - ALREADY_PUBLISHED: ${{ steps.release.outputs.already_published }} - run: | - set -euo pipefail - release_url="$(gh release view "$TAG" --json url --jq '.url')" - { - echo "### Published" - echo "- Version: $RELEASE_VERSION" - echo "- Tag: $TAG" - echo "- Release: $release_url" - echo "- Already complete before this run: $ALREADY_PUBLISHED" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index bf736034..00000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Build the docs site for PR/push confidence. Production deploys are handled -# by the Vercel project linked to apps/docs and the 1cli.dev domain. - -name: Documentation Build - -on: - pull_request: - paths: - - "apps/docs/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - "mise.toml" - - "packages/templates/registry.json" - - ".github/workflows/docs.yml" - push: - branches: [master] - paths: - - "apps/docs/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - "mise.toml" - - "packages/templates/registry.json" - - ".github/workflows/docs.yml" - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Check out - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Build docs site - run: mise run docs:build diff --git a/.gitignore b/.gitignore index 17aba7f8..1bc3cbff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules/ dist/**/* -coverage/ *.log .DS_Store .env @@ -18,8 +17,6 @@ tmp bin/ go-dist/ *.out -*.test -.golangci-cache/ # seo-cli local state .seo/ @@ -27,7 +24,7 @@ go-dist/ # 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 -# `mise run vet` / `test` / `build`, so the normal mise flow always +# `mise run vet` / `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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index dc597834..00000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,96 +0,0 @@ -# goreleaser config for the Go rewrite of one-cli. -# -# Cuts cross-platform binaries on every git tag, packs them into per-target -# tarballs/zips, and uploads them to a GitHub Release. install.sh downloads -# the matching tarball from those release assets at install time. -# -# Run locally: goreleaser release --snapshot --clean -# Run on tag: goreleaser release --clean (CI / release pipeline) -# -# The release workflow computes RELEASE_VERSION and creates its local tag. -# GoReleaser derives .Version from that tag and injects it through -# ldflags, so no tracked version file needs to change before a release. - -version: 2 - -project_name: one-cli - -builds: - - id: one - dir: packages/cli - main: ./cmd/one - binary: one - env: - - CGO_ENABLED=0 - goos: - - darwin - - linux - - windows - goarch: - - amd64 - - arm64 - # No need for windows/arm64 yet — too few users. - ignore: - - goos: windows - goarch: arm64 - ldflags: - - -s -w - - -X main.version={{.Version}} - - -X github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck.buildChannel=release - # Published releases restore One skills from this exact source commit. - # Local snapshots retain their embedded, unpublished skill files. - - -X github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills.bundledSourceRef={{ if not .IsSnapshot }}{{ .FullCommit }}{{ end }} - flags: - - -trimpath - -archives: - - id: default - formats: - - tar.gz - format_overrides: - - goos: windows - formats: - - zip - # install.sh resolves binaries by archive name. Keep the template shape - # (one-cli_{os}_{arch}) stable so older installers keep working when we - # cut newer releases. - name_template: "one-cli_{{ .Os }}_{{ .Arch }}" - files: - - README* - - third_party/mise/LICENSE - -checksum: - name_template: "checksums.txt" - algorithm: sha256 - -snapshot: - version_template: "{{ incpatch .Version }}-snapshot" - -changelog: - # We keep CHANGELOG.md authoritative. goreleaser's auto-generated entries - # are noisy and conflict with the curated narrative. - disable: true - -release: - # install.sh downloads from /releases/download// so the - # exact GitHub repo coordinates here matter. Adjust if the repo moves. - github: - owner: 1cli-team - name: one-cli - draft: true - # A failed publish may already have created the tag and part of its draft. - # Reuse that draft and replace duplicate assets so the publish operation is - # safe to retry instead of silently skipping to the next version. - use_existing_draft: true - replace_existing_artifacts: true - make_latest: true - prerelease: auto - header: | - # one-cli {{ .Tag }} - - Pre-built binaries for darwin/linux/windows × amd64/arm64. - Install via the curl one-liner (see README), or download the - matching archive below and extract `one` onto your PATH. - - footer: | - Verify your download with `sha256sum -c checksums.txt`. diff --git a/AGENTS.md b/AGENTS.md index 532e70ea..fd277a90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,4 +41,4 @@ - 两份语言字典的键必须完整对应、译文非空、格式占位符兼容。新增翻译键时确认实际使用处能够解析。 - 根据改动覆盖两种语言的帮助、交互、错误或结果输出,并检查语言切换后没有旧语言残留。涉及持久化偏好的测试使用临时 HOME/config,避免修改开发者设置。 - 修改提示组件或 TUI 时,检查中文终端显示宽度、快捷键提示和文字对比度,并确认子进程原始输出仍被保留。 -- 文案变化需要审阅并更新相关帮助/输出快照;运行与改动相关的测试,并按仓库现有流程执行 `mise run check`。 +- 文案变化需要审阅相关帮助和输出;运行与改动相关的构建及静态检查,并按仓库现有流程执行 `mise run check`。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 823741c1..df4685d4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -129,8 +129,6 @@ packages/cli/internal/ packages/cli/ cmd/one/ thin executable entry point pkg/ intentionally public Go packages - testdata/ stable fixtures and compatibility snapshots - tests/e2e/ binary and full-command contract tests tools/ repository verification programs apps/dashboard/src/ @@ -173,13 +171,11 @@ Rules: reversible transaction. 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. -12. `packages/kernel` imports only the Go standard library. It never owns +11. `packages/kernel` imports only the Go standard library. It never owns Workspace, Project, Environment, Backend, Profile, or Template policy. -13. Workspace modules are listed explicitly in `go.work`; embedded Go template +12. Workspace modules are listed explicitly in `go.work`; embedded Go template modules under `packages/templates` remain standalone template fixtures. -14. `packages/kernel` follows the applicable parts of the Go project-layout +13. `packages/kernel` follows the applicable parts of the Go project-layout convention: public APIs live under `pkg/kernel`, private implementation lives under `internal`, and design notes live under `docs`. Executable and deployment directories are intentionally absent because Kernel is a @@ -275,9 +271,11 @@ Historical manifest-mutation route paths fail closed with HTTP 409 and `SERVE_REPOSITORY_READ_ONLY`; they never silently ignore a requested write. Workspace discovery across invocations is a separate machine-local registry, -not Profile state and not Kernel state. `one create` observes a Workspace only -after successful creation; `one serve` observes the nearest manifest found by -walking up from its launch directory. Both update the XDG-aware +not Profile state and not Kernel state. `one create` registers a Workspace as soon +as its generated manifest is valid, before optional tooling and skills setup; +`one serve` registers the nearest manifest found by walking up from its launch +directory. With no enclosing manifest, it opens the global Dashboard and loads +the existing registry. Both update the XDG-aware `workspaces.json` through `application/workspace.RegistryService` and the local registry adapter. The registry stores only an opaque local entry id, manifest identity, canonical root, display name, and observation timestamps. Projects, @@ -304,7 +302,7 @@ Finite workspace tasks are owned by `modules/tasks` and `modules/miseconfig`: - 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 remain supported. +- Hooks remain optional and are configured explicitly with `one init hooks`. Environment is a vertical deep module because its two built-in backends are compiled implementation components rather than independently distributed @@ -393,14 +391,12 @@ Dashboard dependencies point inward: `router` composes `pages`, pages compose features, and features may use API wrappers and shared UI primitives. Only the router imports routed pages; features never import pages or the router. API wrappers do not import presentation code, and `components/ui` remains a leaf -view layer. `src/architecture/dependencies.test.ts` enforces these rules and -rejects local TypeScript barrel entrypoints so imports stay directly -analyzable. +view layer. Local TypeScript imports use direct module paths rather than barrel +entrypoints so dependencies stay directly analyzable. 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. `mise run pre-push` adds Go race detection without -creating a separate definition of the PR gate. +root `pnpm check`). It runs `check:static` for documentation, Go vet and +formatting, and Dashboard lint and formatting. Node dependency resolution is likewise repository-owned: root `pnpm-workspace.yaml`, `package.json`, and `pnpm-lock.yaml` are the only diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7fcf562..f5773219 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ one --version # 验证装好 > **fresh-clone 提示**:`packages/cli/internal/resources/bundled/` 整个目录是 gitignore 的—— > registry / templates / dashboard dist 都由 `mise run sync-bundled` + -> `mise run sync-web` 按需重建。这些任务作为 `mise run vet` / `test` / `build` 的依赖自动运行; +> `mise run sync-web` 按需重建。这些任务作为 `mise run vet` / `build` 的依赖自动运行; > One 使用官方 Process Compose;贡献者先安装 mise,再使用根任务准备和构建资源。 > 第一次 `mise run install` 会准备依赖并构建 Dashboard;之后输入未变时复用 Dashboard 产物缓存。 > 如果你直接跑 `go build` 而不走 mise, @@ -36,7 +36,7 @@ one --version # 验证装好 | `cli` | `packages/cli` | Go CLI 和公开 Go 包 | | `kernel` | `packages/kernel` | 共享 Go 内核 | -`packages/templates` 和测试 fixture 是源码素材,不登记为项目。`packages/cli` 保留现有路径,因为公开 Go 包的 module path 已包含该目录。 +`packages/templates` 是源码素材,不登记为项目。`packages/cli` 保留现有路径,因为公开 Go 包的 module path 已包含该目录。 已有 checkout 升级后也先运行 `mise run install`,重新构建本地新版并将 `one` 启动器指向 `packages/cli/bin/one`,避免继续调用旧发行版。 @@ -48,63 +48,41 @@ one run # 查看根任务和项目任务 one run dev # Go Dashboard API + Vite UI,管理当前真实工作区 one run dev -p docs # 文档站:http://localhost:3000 one run build -p cli # 准备嵌入资源并构建 CLI -one run test -p kernel # 测试共享 Go 内核 one run check # 完整仓库检查;也可简写 one check one serve # 在 Dashboard 中管理当前真实仓库 ``` -单独运行 `one run dev -p dashboard` 只启动 Vite 前端,需要在另一终端运行 `one run dev -p cli` 提供 API;通常直接使用联合任务 `one run dev`。开发 API 与 `one serve` 都管理当前真实仓库。构建、测试和开发不需要绑定 Infisical。 +单独运行 `one run dev -p dashboard` 只启动 Vite 前端,需要在另一终端运行 `one run dev -p cli` 提供 API;通常直接使用联合任务 `one run dev`。开发 API 与 `one serve` 都管理当前真实仓库。构建、静态检查和开发不需要绑定 Infisical。 `one run dev --ui tui` 使用 One 的运行中列表、完整任务树和日志界面,官方 Process Compose 以 headless 模式调度;`--ui stream` 使用流式输出。Tab 切换焦点,方向键展开/定位;拖选后 y 复制,c 关闭鼠标报告并固定全宽日志供终端原生复制,Esc 返回。无需声明 service。状态来自实际命令开始/结束事件,不从输出猜测。多任务交互终端默认 TUI,CI 和结构化输出使用 stream;全部完成后自动关闭,Ctrl+C 取消并恢复终端。完整日志保存在私有临时 journal,不受上游 10,000 行缓存限制。 mise 安装固定的 Process Compose 1.122.0,One 生成每次运行的私有配置;变量通过内存认证通道传给叶子进程。无需 Rust、补丁或嵌入调度器构建。首版仅支持静态任务子集,能力和限制见 [one run 文档](apps/docs/content/docs/zh/run.md)。Process Compose 没有全局并发上限,不再承诺任意 `--concurrency` 生效。 -根 `mise.toml` 继续维护原生任务,供首次安装、CI 和 Git hooks 使用: +根 `mise.toml` 继续维护原生任务,供首次安装和本地检查使用: ```bash mise run install-deps # 安装锁定的 Node workspace 依赖 -mise run check # 当前平台的静态检查、Go 和 Dashboard 测试 +mise run check # Go、Dashboard 和文档静态检查 mise run build # 编译到 packages/cli/bin/one -mise run test # Go race 测试 + Dashboard 测试 mise run install # 打包并安装本地启动器,无需预先安装 one -mise run pre-push # 推送前检查,包含 race 测试和模板构建 ``` -项目任务来自各自的 `package.json` 或 `Taskfile.yml`,只在根 `mise.toml` 登记 `cli:build`、`dashboard:dev` 这样的任务入口,并通过 `dir` 指定子项目目录。根文件也声明 Dashboard、模板和嵌入资源的前置步骤,CLI 测试会先构建 E2E 使用的二进制。新增或修改任务目录后,运行 `one init mise` 同步这些受版本控制的原生任务。执行和查询不会重新生成配置;Dashboard 从进程输出发现访问地址,dev 命令在 mise.toml 中定义,由 Process Compose 执行。 +项目任务来自各自的 `package.json` 或 `Taskfile.yml`,只在根 `mise.toml` 登记 `cli:build`、`dashboard:dev` 这样的任务入口,并通过 `dir` 指定子项目目录。根文件也声明 Dashboard、模板和嵌入资源的前置步骤。新增或修改任务目录后,运行 `one init mise` 同步这些受版本控制的原生任务。执行和查询不会重新生成配置;Dashboard 从进程输出发现访问地址,dev 命令在 mise.toml 中定义,由 Process Compose 执行。 ## 提交流程 -1. 起一个分支:`git checkout -b feat/` 或 `fix/` -2. 改代码 + 测试 +1. 起一个分支:`git checkout -b feat/` 或 `fix/`。 +2. 改代码,运行 `mise run check` 和相关构建。 3. 提交:commit 消息走 [conventional commits](https://www.conventionalcommits.org/) - (`feat:`、`fix:`、`chore:`、`docs:`、`test:`、`refactor:` 等)。仓库的 - pre-commit hook 会自动运行当前平台的 `mise run check`,覆盖静态检查、普通 Go 测试 - 和 Dashboard 测试;如果当前 checkout 尚未启用 hook,先运行 `mise run hooks:install`。 - 该命令同时启用 pre-commit 和 pre-push。pre-commit 会拒绝混合已暂存、未暂存或未跟踪的 - 文件,确保本地检查的内容与即将提交、随后由 CI 检查的快照一致 -4. 推送:pre-push hook 会自动运行 `mise run --jobs 1 pre-push`,包含 `check`、 - Go race detector 和模板源码/生成项目构建,检查失败会阻止推送。也可提前运行 - `mise run --jobs 1 pre-push`;模板构建需要下载依赖,因此比提交检查耗时更长。 - pre-push 要求工作区干净,避免用未提交的修复验证将要推送的旧提交 -5. 开 PR,等待各平台 CI 验证 - -PR CI 在 Linux 上并行执行 `mise run check:static`、`mise run check:test` 与 -`mise run test:go`(Go race detector);Linux 的 test job 还执行 `check:templates`。 -同时在 Windows 上执行 `mise run check`、 -macOS 上执行 `mise run check:test`。master 的保护规则要求 `lint`、`test`、 -`test-windows`、`test-macos`、`test-race` 五项检查全部通过才能合并。 -本地 `mise run check` 和 `mise run pre-push` 只验证当前操作系统,不能代替其他平台的 -CI;`mise run pre-push` 额外运行 Go race detector 和模板构建。Windows/macOS 的原生 -运行不能由 Linux 交叉编译代替。路径相关测试应覆盖符号链接、规范化路径,以及 -同一文件的不同路径写法,避免只在 Linux 上通过。远端五项检查会在 PR、master -推送和手动触发的工作流中运行。 + (`feat:`、`fix:`、`chore:`、`docs:`、`refactor:` 等)。 +4. 推送并开 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 条目;测试会强制对应;改完跑 `mise run gen-error-codes` 刷新文档 +- 加新错误码:在 `packages/cli/internal/platform/errors/codes.go` 注册 `Code` 常量 + `Codes` map 条目;改完跑 `mise run gen-error-codes` 刷新文档 ### mise 运行时 @@ -114,8 +92,6 @@ CI;`mise run pre-push` 额外运行 Go race detector 和模板构建。Windows 升级时验证上游校验文件的签名,更新 `packages/cli/internal/adapters/runtime/mise/miserelease/release.go` 中的版本、压缩包和程序 SHA256,再更新需要提高的 runtime 最低版本及相关文档。托管版本随 One 更新,不通过 `mise self-update` 维护。上游许可证保留于 `third_party/mise/LICENSE` 和 One 发布归档。 -下载器及安装器测试使用本地 HTTP fixture,覆盖并发、重试、取消、摘要和删除修复。`ONE_TEST_MISE_BINARY=/absolute/path/to/mise go test ./packages/cli/tests/e2e -run Mise` 启用真实配置与信任测试;其中托管迁移测试要求与仓库固定摘要一致的官方程序。真实下载和多平台冒烟验证在发布前单独执行,不作为普通构建的资源依赖。 - ### 改 templates(`packages/templates//`) - 模板会被 `go:embed` 进二进制(`mise run sync-bundled` 是同步入口,自动跑) @@ -127,57 +103,26 @@ CI;`mise run pre-push` 额外运行 Go race detector 和模板构建。Windows - 前后端联调:在仓库根目录运行 `one run dev`,打开 `http://localhost:5173/` - 开发 API 使用当前仓库的真实项目与任务;页面保存项目配置会更新当前仓库的 manifest,账号使用本机 One 登录会话 - 本地开发:先在仓库根目录运行 `pnpm install`,再运行 `pnpm --filter one-serve-web dev` -- 静态检查:`mise run check:dashboard`;架构护栏和交互测试包含在 `mise run check:test` -- 改完后 `mise run vet` / `test` / `build` 会自动跑 `sync-web`(pnpm install + vite build) +- 静态检查:`mise run check:dashboard`;完整仓库检查:`mise run check` +- 改完后 `mise run vet` / `build` 会自动跑 `sync-web`(pnpm install + vite build) 并刷 `packages/cli/internal/resources/bundled/_web/` ### 改文档站(`apps/docs/`) - 文档站是 Next.js + Fumadocs SSG - 本地预览:在仓库根目录运行 `one run dev -p docs` -- 线上部署:Vercel 项目 Root Directory 指向 `apps/docs`,Output Directory 用 `dist`,域名绑定 `1cli.dev` - `apps/docs/content/docs/reference/error-codes.md` **不要手工编辑**——跑 `mise run gen-error-codes` 重生成 - 新增页面要更新对应目录的 `meta.json`(sidebar 顺序) ### 改 install.sh(`apps/docs/public/install.sh`) -- 改完跑 `mise run test` —— `packages/cli/tests/e2e/install_sh_test.go` 会做静态检查(语法、必要 sentinels、wrap-in-main 不变量) - -## 测试约定 - -```bash -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` 存在 —— 跑 `mise run build` 之后再跑。 - -## 发布流程 - -发布统一从 GitHub Actions 的 **Build and Release** 手动触发: - -1. 在 `master` 上运行工作流,选择 `patch`(默认)、`minor` 或 `major`。 -2. 工作流从最高稳定 tag 自动计算下一版本,并把结果作为 `RELEASE_VERSION`;若最高 tag 位于 `master` 且尚未完成发布,则优先续跑该版本。 -3. 工作流执行完整 `mise run pre-push` 和 GoReleaser no-publish 预构建;验证通过后创建计算出的 tag,生成 5 个平台归档及 `checksums.txt`。 -4. 只有 asset 集合完整时,GitHub Release 才会从 draft 转为公开发布。 - -不需要为了发布修改或提交任何版本文件,也不要手工推 tag。若发布在 tag 或 draft 创建后失败,修复问题后重新运行;工作流会自动识别安全的未完成 tag 并复用 draft。已经完整发布的版本不会被重复发布。 - -发布 channel: - -- **GitHub Releases** — `install.sh` 下载二进制和 `checksums.txt` 的来源 -- **Vercel** `https://1cli.dev` — 文档站和 `install.sh` -- ~~**npm `qzkpwoxtl`**~~ — v0.4.1 起停发 +- 改完运行 `bash -n apps/docs/public/install.sh` 检查脚本语法。 ## 环境变量(开发时常用) | 变量 | 用途 | |---|---| | `ONE_BINARY_PATH` | 让 wrapper / 子 shell 用某个特定 binary | -| `UPDATE_SNAPSHOTS=1` | E2E 测试重写 snapshot | -| `INFISICAL_UNIVERSAL_AUTH_*` | secrets 测试需要(一般 mock,跳过 live) | ## 仓库布局 @@ -188,14 +133,12 @@ packages/cli/ # Go module(module path 含 /packages/cli 后 pkg/ # 公开 Go API(semver 保护) internal/resources/bundled/ # go:embed 镜像,目录整个 gitignore, # 由 mise run sync-bundled + sync-web 重建 - testdata/ # Go 测试 fixtures tools/ # 内部生成器 / 校验器 # gen-error-codes / verify-cli-references / verify-help 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 -mise.toml / .goreleaser.yaml # 顶层编排(路径都按上面这套) +mise.toml # 本地任务编排 pnpm-workspace.yaml # apps/* + packages/* DESIGN.md / apps/docs/design/ # 设计源 ``` diff --git a/README.md b/README.md index 64122634..d632c01a 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,9 @@ The assistant can read `one.manifest.toml` and project README files, then use On One CLI manages variables in Infisical and injects them directly into commands. Workspace bindings live in the manifest `[env.infisical]` table; `.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 binding changes use a reviewed, revision-checked TOML draft; project settings display derived configuration. Remote variable edits take effect immediately; lists omit values and reveal/copy fetch plaintext only on demand. +Run `one serve` from any directory for account settings, workspaces, and shared credentials. `one create` registers new workspaces automatically; running `one serve` inside an existing workspace or its subdirectories registers it and opens its page. Workspace binding changes use a reviewed, revision-checked TOML draft; project settings display derived configuration. 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 exec --global --env dev --path /folder --keys KEY -- command`. One does not print injected values. Child logs preserve their original content and formatting. +Login automatically creates or reuses accessible `shared-credentials` in the current organization. Old account bindings are updated silently, and remote data is retained. Retry failed preparation 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`. One does not print injected values. Child logs preserve their original content and formatting. ## Project Map @@ -144,7 +144,7 @@ If you want to work on One CLI itself, the repository is organized like this: | `apps/dashboard` | Local workspace, account, and global-variable Dashboard opened by `one serve` | | `assets` | Brand assets, including the logo | -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. +This repository is also a One CLI workspace: `dashboard`, `docs`, `cli`, and `kernel`. Template directories under `packages/templates` 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`: @@ -164,16 +164,15 @@ one run # List root and project tasks one run dev # Dashboard API + Vite UI for this workspace one run dev -p docs # Documentation at http://localhost:3000 one run build -p cli # Prepare embedded resources and build the CLI -one run 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 run dev -p dashboard` starts the Vite UI; run `one run dev -p cli` in another terminal for its API, or use the combined `one run dev` task. Both the development API and `one serve` use this repository as their workspace. No Infisical binding is required to build, test, or start these projects. +`one run dev -p dashboard` starts the Vite UI; run `one run dev -p cli` in another terminal for its API, or use the combined `one run dev` task. Both the development API and `one serve` use this repository as their workspace. No Infisical binding is required to build, check, 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 native tasks. Running or listing tasks does not regenerate configuration. Dashboard discovers access links from process output; dev commands are defined in mise.toml and executed by Process Compose. +Root tasks remain native mise commands, so local checks and first-time installation can 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. Run `one init mise` after changing the project task catalogue to refresh the tracked native tasks. Running or listing tasks does not regenerate configuration. Dev commands are defined in mise.toml and executed by Process Compose in the terminal. -Run `mise run hooks:install` after cloning to enable this repository's Git hooks. Pre-commit runs `mise run check` for static checks, ordinary Go tests, and Dashboard tests on your current platform. Pre-push runs `mise run --jobs 1 pre-push`, adding Go race tests and template source/generated-project builds; failures stop the push. Template builds download dependencies and take longer than commit checks. Native Windows and macOS behavior is verified by the PR's CI jobs, even when local Linux checks pass. +Run `mise run check` for Go vet, formatting, Dashboard lint, and documentation checks. Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request. diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 1ec7e149..4150b1ec 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -8,8 +8,6 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest", "lint": "oxlint .", "lint:fix": "oxlint --fix .", "format": "oxfmt --check .", @@ -37,18 +35,13 @@ "zustand": "^5.0.15" }, "devDependencies": { - "@testing-library/react": "^16.3.3", - "@testing-library/user-event": "^14.6.7", "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", - "jsdom": "^30.1.1", - "msw": "^2.15.0", "oxfmt": "^0.70.0", "oxlint": "^1.85.0", "typescript": "^7.0.2", - "vite": "^8.3.1", - "vitest": "^5.0.2" + "vite": "^8.3.1" } } diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 94e46b9a..225f20b2 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; export const App: React.FC = () => { const workspaceMode = Boolean(useMatch("/workspace/:entryId")); + const globalMode = Boolean(useMatch("/global")); return (
@@ -18,7 +19,11 @@ export const App: React.FC = () => { )} >
diff --git a/apps/dashboard/src/api/services.ts b/apps/dashboard/src/api/services.ts deleted file mode 100644 index 3af25972..00000000 --- a/apps/dashboard/src/api/services.ts +++ /dev/null @@ -1,36 +0,0 @@ -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/session.ts b/apps/dashboard/src/api/session.ts index 54ab0b36..3a0a5848 100644 --- a/apps/dashboard/src/api/session.ts +++ b/apps/dashboard/src/api/session.ts @@ -10,6 +10,7 @@ export interface SessionInfo { } export interface SessionState { session: SessionInfo; + sharedCredentials?: SharedCredentialsState | null; login?: { status: "waiting" | "complete" | "failed"; url: string }; error?: string; } @@ -30,6 +31,11 @@ export const createRemoteProject = (name: string) => http.post("/infisical/projects", { name }, { timeout: 120000 }); export const getProject = (id: string) => http.get(`/infisical/projects/${encodeURIComponent(id)}`); +// Metadata from another account or instance must not populate a binding form. +export const remoteProjectsKey = (account?: SessionInfo, projectId = "") => + account?.loggedIn + ? ["/infisical/projects", account.siteUrl, account.userId, account.organizationId, projectId] + : null; export interface GlobalLocation { siteUrl: string; userId: string; @@ -38,6 +44,11 @@ export interface GlobalLocation { projectName: string; defaultEnvironment: string; } +export interface SharedCredentialsState { + status: "preparing" | "ready" | "failed"; + location?: GlobalLocation; + error?: string; +} export interface GlobalListing { location: GlobalLocation; environment: string; @@ -45,14 +56,16 @@ export interface GlobalListing { folders: string[]; variables: { key: string; description?: string }[]; } -export const locationKey = "/global-env/location"; -export const getLocation = () => http.get<{ location: GlobalLocation | null }>(locationKey); -export const bindLocation = (projectId: string, environment: string) => - http.put<{ location: GlobalLocation }>(locationKey, { projectId, environment }); export const initializeGlobalLocation = () => - http.post<{ location: GlobalLocation }>(`${locationKey}/default`, {}, { timeout: 120000 }); + http.post<{ sharedCredentials: SharedCredentialsState }>( + "/global-env/location/default", + {}, + { timeout: 120000 }, + ); export const globalQuery = (environment: string, path: string) => `?${new URLSearchParams({ env: environment, path })}`; +export const globalListingKey = (location: GlobalLocation, query: string) => + `/global-env/secrets:${JSON.stringify([location.siteUrl, location.userId, location.organizationId, location.projectId])}${query}`; export const getGlobalListing = (query: string) => http.get(`/global-env/secrets${query}`); export const readGlobalSecret = (key: string, query: string) => diff --git a/apps/dashboard/src/architecture/dependencies.test.ts b/apps/dashboard/src/architecture/dependencies.test.ts deleted file mode 100644 index 2244b6b4..00000000 --- a/apps/dashboard/src/architecture/dependencies.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { basename, join, relative, sep } from "node:path"; -import { describe, expect, it } from "vitest"; - -const SOURCE_ROOT = join(process.cwd(), "src"); -const SOURCE_EXTENSIONS = new Set([".ts", ".tsx"]); - -interface DependencyRule { - from: string; - forbidden: readonly string[]; -} - -const DEPENDENCY_RULES: readonly DependencyRule[] = [ - { - from: "api", - forbidden: ["components", "features", "hooks", "pages", "providers", "router"], - }, - { - from: "components/ui", - forbidden: ["api", "features", "hooks", "pages", "providers", "router"], - }, - { from: "features", forbidden: ["pages", "router"] }, - { from: "pages", forbidden: ["pages", "router"] }, -]; - -describe("Dashboard dependency boundaries", () => { - it("keeps imports pointing inward", () => { - const violations: string[] = []; - for (const file of productionSourceFiles()) { - const source = sourcePath(file); - const imports = aliasImports(readFileSync(file, "utf8")); - const rule = DEPENDENCY_RULES.find(({ from }) => inArea(source, from)); - - for (const imported of imports) { - const target = imported.slice(2); - if (target.startsWith("pages/") && !inArea(source, "router")) { - violations.push(`${source} imports routed page ${imported}`); - continue; - } - const blocked = rule?.forbidden.find((area) => inArea(target, area)); - if (blocked) { - violations.push(`${source} imports outer area ${imported}`); - } - } - } - - expect(violations, violations.join("\n")).toEqual([]); - }); - - it("uses direct source imports instead of local barrel entrypoints", () => { - const violations: string[] = []; - for (const file of productionSourceFiles()) { - const source = sourcePath(file); - const contents = readFileSync(file, "utf8"); - if (/^index\.tsx?$/.test(basename(file)) && /\bexport\s+(?:\*|\{)/.test(contents)) { - violations.push(`${source} is a local barrel file`); - } - - for (const imported of aliasImports(contents)) { - const target = join(SOURCE_ROOT, imported.slice(2)); - if (existsSync(target) && statSync(target).isDirectory()) { - violations.push(`${source} imports directory barrel ${imported}`); - } - } - } - - expect(violations, violations.join("\n")).toEqual([]); - }); - - it("keeps interactive primitives inside components/ui", () => { - const violations: string[] = []; - const forbidden = [ - { pattern: /" }, - { pattern: /" }, - { pattern: /" }, - { pattern: /" }, - { pattern: /\bwindow\.confirm\s*\(/g, label: "window.confirm" }, - ] as const; - - for (const file of productionSourceFiles()) { - const source = sourcePath(file); - if (inArea(source, "components/ui")) continue; - const contents = readFileSync(file, "utf8"); - for (const { pattern, label } of forbidden) { - if (pattern.test(contents)) violations.push(`${source} uses ${label}`); - pattern.lastIndex = 0; - } - } - - expect(violations, violations.join("\n")).toEqual([]); - }); -}); - -function productionSourceFiles(root = SOURCE_ROOT): string[] { - const files: string[] = []; - for (const entry of readdirSync(root, { withFileTypes: true })) { - const path = join(root, entry.name); - if (entry.isDirectory()) { - if (entry.name !== "test") files.push(...productionSourceFiles(path)); - continue; - } - const extension = entry.name.endsWith(".tsx") - ? ".tsx" - : entry.name.endsWith(".ts") - ? ".ts" - : ""; - if ( - SOURCE_EXTENSIONS.has(extension) && - !entry.name.includes(".test.") && - !entry.name.endsWith(".d.ts") - ) { - files.push(path); - } - } - return files.sort(); -} - -function aliasImports(source: string): string[] { - const imports: string[] = []; - const patterns = [ - /\bfrom\s+["'](@\/[^"']+)["']/g, - /\bimport\s+["'](@\/[^"']+)["']/g, - /\bimport\s*\(\s*["'](@\/[^"']+)["']\s*\)/g, - ]; - for (const pattern of patterns) { - for (const match of source.matchAll(pattern)) { - if (match[1]) imports.push(match[1]); - } - } - return imports; -} - -function sourcePath(file: string): string { - return relative(SOURCE_ROOT, file).split(sep).join("/"); -} - -function inArea(path: string, area: string): boolean { - return path === area || path.startsWith(`${area}/`); -} diff --git a/apps/dashboard/src/components/AppMenu.test.tsx b/apps/dashboard/src/components/AppMenu.test.tsx deleted file mode 100644 index 3884eef7..00000000 --- a/apps/dashboard/src/components/AppMenu.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -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=staging", - ); - 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=staging"); - }, - ); - - 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=staging", - ); - }); -}); diff --git a/apps/dashboard/src/components/AppMenu.tsx b/apps/dashboard/src/components/AppMenu.tsx index d366aefc..ab373dfe 100644 --- a/apps/dashboard/src/components/AppMenu.tsx +++ b/apps/dashboard/src/components/AppMenu.tsx @@ -1,4 +1,4 @@ -import { Boxes, ChevronDown, House, KeyRound, Settings2 } from "lucide-react"; +import { ChevronDown, House, KeyRound, Settings2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -66,15 +66,6 @@ export function AppMenu() { {t("global.title")} - - - - diff --git a/apps/dashboard/src/components/TopBar.test.tsx b/apps/dashboard/src/components/TopBar.test.tsx deleted file mode 100644 index 71dc1713..00000000 --- a/apps/dashboard/src/components/TopBar.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router-dom"; -import { SWRConfig } from "swr"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { applyManifestDraft, previewManifestDraft } from "@/api/manifest"; -import { workspacesKey } from "@/api/workspaces"; -import { ManifestSaveControl, TopBar } from "@/components/TopBar"; -import { useManifestDraftStore } from "@/features/manifest-draft/manifest-draft-store"; -import i18n from "@/lib/i18n"; -import type { WorkspacesResponse } from "@/types/api"; - -vi.mock("@/api/manifest", () => ({ - applyManifestDraft: vi.fn(), - previewManifestDraft: vi.fn(), -})); - -const emptyRegistry: WorkspacesResponse = { - schema: "one-cli/workspaces/v1", - workspaces: [], -}; - -function renderTopBar(path: string) { - return render( - new Map(), - fallback: { [workspacesKey]: emptyRegistry }, - revalidateOnMount: false, - }} - > - - - - , - ); -} - -function renderManifestSaveControl(path: string) { - return render( - new Map(), revalidateOnMount: false }}> - - - - , - ); -} - -describe("TopBar and manifest review", () => { - beforeAll(async () => { - await i18n.changeLanguage("en-US"); - }); - beforeEach(() => { - vi.mocked(previewManifestDraft).mockResolvedValue({ - schema: "one-cli/workspace-manifest-preview/v1", - revision: "sha256:base", - before: - "version = 2\n[env.infisical]\nprojectId = 'old-project'\nenvironments = ['dev','staging','prod']\n", - after: - "version = 2\n[env.infisical]\nprojectId = 'new-project'\nenvironments = ['dev','staging','prod']\n", - }); - }); - afterEach(() => { - useManifestDraftStore.getState().clearWorkspace("demo-entry"); - vi.clearAllMocks(); - }); - - it.each([ - "/", - "/settings?env=staging", - "/settings/env/infisical?env=staging", - "/profile?env=staging", - "/section/env/infisical?env=staging", - ])("keeps the global TopBar free of workspace environment controls at %s", (path) => { - renderTopBar(path); - expect(screen.queryByRole("combobox", { name: /^Environment:/ })).toBeNull(); - }); - - it("reviews and publishes a Workspace Infisical binding draft", async () => { - useManifestDraftStore.getState().stageWorkspaceSection({ - entryId: "demo-entry", - revision: "sha256:base", - section: "environment", - 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(/projectId = 'old-project'/)).toBeDefined(); - expect(within(dialog).getByText(/projectId = 'new-project'/)).toBeDefined(); - expect(previewManifestDraft).toHaveBeenCalledWith( - { - revision: "sha256:base", - workspace: { environment: { backend: "infisical", projectId: "new-project" } }, - changes: [], - }, - "demo-entry", - ); - - await user.click(within(dialog).getByRole("button", { name: "Save to manifest" })); - await waitFor(() => - expect(applyManifestDraft).toHaveBeenCalledWith( - { - revision: "sha256:base", - workspace: { environment: { backend: "infisical", projectId: "new-project" } }, - changes: [], - }, - "demo-entry", - ), - ); - }); - - it("retains the entire draft when atomic publication fails", async () => { - vi.mocked(applyManifestDraft).mockRejectedValue({ - status: 500, - code: "ONE_CLI_ERROR", - message: "Project publication failed.", - context: {}, - remediation: [], - }); - useManifestDraftStore.getState().stageWorkspaceSection({ - entryId: "demo-entry", - revision: "sha256:base", - section: "environment", - 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"); - const saveButton = within(dialog).getByRole("button", { name: "Save to manifest" }); - await waitFor(() => expect((saveButton as HTMLButtonElement).disabled).toBe(false)); - await user.click(saveButton); - expect(await screen.findByText("Project publication failed.")).toBeDefined(); - expect(applyManifestDraft).toHaveBeenCalledWith( - { - revision: "sha256:base", - workspace: { environment: { backend: "infisical", projectId: "new-project" } }, - changes: [], - }, - "demo-entry", - ); - const remaining = useManifestDraftStore.getState().drafts["demo-entry"]; - expect(remaining.revision).toBe("sha256:base"); - expect(remaining.workspace).toEqual({ - environment: { backend: "infisical", projectId: "new-project" }, - }); - expect(Object.keys(remaining.changes)).toEqual([]); - }); -}); diff --git a/apps/dashboard/src/components/TopBar.tsx b/apps/dashboard/src/components/TopBar.tsx index 7518416c..d201906c 100644 --- a/apps/dashboard/src/components/TopBar.tsx +++ b/apps/dashboard/src/components/TopBar.tsx @@ -32,7 +32,6 @@ export const TopBar: React.FC = () => { 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"); @@ -52,10 +51,6 @@ export const TopBar: React.FC = () => { match={detailMatch.params} settingsRoute={Boolean(settingsSectionMatch)} /> - ) : templatesMatch ? ( - - {t("templateCatalog.title")} - ) : globalMatch ? ( {t("global.title")} diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx index f9340d5e..b03b6343 100644 --- a/apps/dashboard/src/components/ui/button.tsx +++ b/apps/dashboard/src/components/ui/button.tsx @@ -23,6 +23,7 @@ const buttonVariants = cva( }, size: { navigation: "h-auto min-h-14 w-full gap-3 px-3 py-2", + "navigation-compact": "h-8 w-full min-w-0 max-w-full gap-2 px-2 py-1", default: "h-8 px-3 py-1 has-[>svg]:px-3", xs: "h-6 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1.5 px-3 text-xs has-[>svg]:px-2.5", diff --git a/apps/dashboard/src/components/ui/modal-focus.test.tsx b/apps/dashboard/src/components/ui/modal-focus.test.tsx deleted file mode 100644 index 0c029ee2..00000000 --- a/apps/dashboard/src/components/ui/modal-focus.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { useState } from "react"; -import { describe, expect, it } from "vitest"; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; -import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"; - -describe("controlled modal focus", () => { - it("returns focus after a controlled Sheet without a rendered trigger closes", async () => { - const user = userEvent.setup(); - render(); - const opener = screen.getByRole("button", { name: "Open inspector" }); - - await user.click(opener); - const dialog = await screen.findByRole("dialog"); - await user.click(screen.getByRole("button", { name: "Close" })); - await waitFor(() => expect(dialog.isConnected).toBe(false)); - - expect(document.activeElement).toBe(opener); - }); - - it("returns focus after a controlled AlertDialog without a rendered trigger closes", async () => { - const user = userEvent.setup(); - render(); - const opener = screen.getByRole("button", { name: "Forget workspace" }); - - await user.click(opener); - const dialog = await screen.findByRole("alertdialog"); - await user.click(screen.getByRole("button", { name: "Cancel" })); - await waitFor(() => expect(dialog.isConnected).toBe(false)); - - expect(document.activeElement).toBe(opener); - }); - - it("returns focus after a controlled Dialog without a rendered trigger closes", async () => { - const user = userEvent.setup(); - render(); - const opener = screen.getByRole("button", { name: "Add profile" }); - - await user.click(opener); - const dialog = await screen.findByRole("dialog"); - await user.click(screen.getByRole("button", { name: "Close" })); - await waitFor(() => expect(dialog.isConnected).toBe(false)); - - expect(document.activeElement).toBe(opener); - }); -}); - -function ControlledSheet() { - const [open, setOpen] = useState(false); - return ( - <> - - - - Project inspector - Configure this project. - - - - ); -} - -function ControlledAlertDialog() { - const [open, setOpen] = useState(false); - return ( - <> - - - - Forget this workspace? - This only removes the registry entry. - Cancel - - - - ); -} - -function ControlledDialog() { - const [open, setOpen] = useState(false); - return ( - <> - - - - Add profile - Add machine-local credentials. - - - - ); -} diff --git a/apps/dashboard/src/features/environment-context/EnvironmentSelector.test.tsx b/apps/dashboard/src/features/environment-context/EnvironmentSelector.test.tsx deleted file mode 100644 index 89b7650c..00000000 --- a/apps/dashboard/src/features/environment-context/EnvironmentSelector.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter, useLocation } from "react-router-dom"; -import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import { EnvironmentSelector } from "@/features/environment-context/EnvironmentSelector"; -import { useEnvironmentDirtyStore } from "@/features/environment-context/environment-dirty-store"; -import i18n from "@/lib/i18n"; - -function LocationProbe() { - const location = useLocation(); - return {location.search}; -} - -function RouteProbe() { - const location = useLocation(); - return {`${location.pathname}${location.search}`}; -} - -function renderSelector(path: string) { - return render( - - - - , - ); -} - -describe("EnvironmentSelector", () => { - beforeAll(async () => { - await i18n.changeLanguage("en-US"); - }); - afterEach(() => { - useEnvironmentDirtyStore.getState().reset(); - }); - - it("defaults to dev without writing the URL on mount", () => { - renderSelector("/workspace/demo?panel=summary"); - - const selector = screen.getByRole("combobox", { name: "Environment: Development" }); - expect(selector.textContent).toContain("Development"); - expect(screen.getByTestId("search").textContent).toBe("?panel=summary"); - }); - - it("uses the env query and preserves every other query parameter", async () => { - const user = userEvent.setup(); - renderSelector("/workspace/demo?view=compact&panel=deploy&env=staging"); - - const selector = screen.getByRole("combobox", { name: "Environment: Staging" }); - expect(selector.textContent).toContain("Staging"); - await user.click(selector); - await user.click(await screen.findByRole("option", { name: "Production" })); - - const search = new URLSearchParams( - screen.getByTestId("search").textContent?.replace(/^\?/, ""), - ); - expect(search.get("env")).toBe("prod"); - expect(search.get("view")).toBe("compact"); - expect(search.get("panel")).toBe("deploy"); - }); - - it("keeps the selected environment while navigating between Dashboard pages", async () => { - const user = userEvent.setup(); - render( - - Open Infisical - - , - ); - - await user.click(screen.getByRole("link", { name: "Open Infisical" })); - - expect(screen.getByTestId("route").textContent).toBe("/settings/env/infisical?env=staging"); - }); - - it("keeps guarding while any independent editor remains dirty", async () => { - const state = useEnvironmentDirtyStore.getState(); - state.setDirty("workspace-editor", true); - state.setDirty("project-editor", true); - state.clearOwner("project-editor"); - expect(useEnvironmentDirtyStore.getState().dirty).toBe(true); - - const user = userEvent.setup(); - renderSelector("/workspace/demo?env=dev"); - await user.click(screen.getByRole("combobox", { name: "Environment: Development" })); - await user.click(await screen.findByRole("option", { name: "Production" })); - - expect(await screen.findByRole("alertdialog")).toBeDefined(); - expect(screen.getByTestId("search").textContent).toBe("?env=dev"); - }); -}); diff --git a/apps/dashboard/src/features/global-variables/FolderTree.tsx b/apps/dashboard/src/features/global-variables/FolderTree.tsx new file mode 100644 index 00000000..f9a7032c --- /dev/null +++ b/apps/dashboard/src/features/global-variables/FolderTree.tsx @@ -0,0 +1,215 @@ +import { useState, type KeyboardEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronRight, Folder, FolderOpen } from "lucide-react"; +import useSWR from "swr"; +import { + getGlobalListing, + globalListingKey, + globalQuery, + message, + type GlobalLocation, +} from "@/api/session"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { ErrorNotice } from "@/components/ui/page-layout"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; + +type Props = { + location: GlobalLocation; + environment: string; + selectedPath: string; + onSelect(path: string): void; + disabled: boolean; +}; + +type BranchProps = Props & { + path: string; + depth: number; + branches: Record; + setExpanded(path: string, expanded: boolean): void; + focusedPath: string; + onFocus(path: string): void; +}; + +export function FolderTree(props: Props) { + const { t } = useTranslation(); + // Remember visited branches and expansion independently of the selected folder. + const [branches, setBranches] = useState>({ "/": true }); + const [focusedPath, setFocusedPath] = useState("/"); + return ( +
    + { + setBranches((current) => + Object.hasOwn(current, path) ? current : { ...current, [path]: false }, + ); + props.onSelect(path); + }} + path="/" + depth={0} + branches={branches} + setExpanded={(path, expanded) => + setBranches((current) => ({ ...current, [path]: expanded })) + } + focusedPath={focusedPath} + onFocus={setFocusedPath} + /> +
+ ); +} + +function FolderBranch(props: BranchProps) { + const { t } = useTranslation(); + const { + path, + depth, + branches, + setExpanded, + selectedPath, + onSelect, + disabled, + focusedPath, + onFocus, + } = props; + const expanded = branches[path] ?? false; + const query = globalQuery(props.environment, path); + const listing = useSWR( + Object.hasOwn(branches, path) || selectedPath === path + ? globalListingKey(props.location, query) + : null, + () => getGlobalListing(query), + ); + const folders = listing.data?.folders; + const expandable = folders === undefined || folders.length > 0; + const name = path === "/" ? t("global.rootFolder") : path.split("/").pop()!; + + function handleKeyDown(event: KeyboardEvent) { + if (event.target !== event.currentTarget || disabled) return; + const item = event.currentTarget; + const items = Array.from( + item.closest('[role="tree"]')?.querySelectorAll('[role="treeitem"]') ?? [], + ); + const index = items.indexOf(item); + switch (event.key) { + case "ArrowDown": + items[Math.min(index + 1, items.length - 1)]?.focus(); + break; + case "ArrowUp": + items[Math.max(index - 1, 0)]?.focus(); + break; + case "Home": + items[0]?.focus(); + break; + case "End": + items.at(-1)?.focus(); + break; + case "ArrowRight": + if (expandable && !expanded) setExpanded(path, true); + else item.querySelector('[role="group"] [role="treeitem"]')?.focus(); + break; + case "ArrowLeft": + if (expandable && expanded) setExpanded(path, false); + else item.parentElement?.closest('[role="treeitem"]')?.focus(); + break; + case "Enter": + case " ": + onSelect(path); + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + } + return ( +
  • { + if (event.target === event.currentTarget) onFocus(path); + }} + onKeyDown={handleKeyDown} + > +
    { + event.stopPropagation(); + if (disabled) return; + event.currentTarget.parentElement?.focus(); + onSelect(path); + }} + > + {expandable ? ( + + ) : ( +
    + {expanded && ( +
      + {listing.isLoading && ( +
    • + + {t("session.loading")} +
    • + )} + {listing.error && ( +
    • + void listing.mutate()}> + {t("secrets.retry")} + + } + > + {message(listing.error)} + +
    • + )} + {!listing.error && + folders?.map((child) => ( + + ))} + {!listing.error && folders?.length === 0 && path === "/" && ( +
    • + {t("global.noFolders")} +
    • + )} +
    + )} +
  • + ); +} diff --git a/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx b/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx deleted file mode 100644 index a8ece2f0..00000000 --- a/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { act, render, screen, waitFor, within } 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 * as api from "@/api/session"; -import i18n from "@/lib/i18n"; -import { GlobalVariables } from "./GlobalVariables"; - -vi.mock("@/api/session", async (original) => ({ - ...(await original()), - getSession: vi.fn(), - getLocation: vi.fn(), - getProject: vi.fn(), - getProjects: vi.fn(), - createRemoteProject: vi.fn(), - initializeGlobalLocation: vi.fn(), - bindLocation: vi.fn(), - getGlobalListing: vi.fn(), - readGlobalSecret: vi.fn(), - saveGlobalSecret: vi.fn(), - deleteGlobalSecret: vi.fn(), - createGlobalFolder: vi.fn(), -})); -const location: api.GlobalLocation = { - siteUrl: "https://app.infisical.com", - userId: "user", - organizationId: "org", - projectId: "shared", - projectName: "Shared", - defaultEnvironment: "dev", -}; -beforeEach(async () => { - vi.resetAllMocks(); - await i18n.changeLanguage("en-US"); - vi.mocked(api.getSession).mockResolvedValue({ - session: { - loggedIn: true, - expired: false, - userId: "user", - siteUrl: location.siteUrl, - organizationId: "org", - }, - }); - vi.mocked(api.getLocation).mockResolvedValue({ location }); - vi.mocked(api.getProjects).mockResolvedValue([]); - vi.mocked(api.getProject).mockResolvedValue({ - id: "shared", - name: "Shared", - orgId: "org", - environments: [ - { name: "Development", slug: "dev" }, - { name: "Production", slug: "prod" }, - ], - }); - vi.mocked(api.getGlobalListing).mockImplementation(async (query) => ({ - location, - environment: new URLSearchParams(query).get("env")!, - path: "/", - folders: [], - variables: [{ key: "OSS_AK", description: "Upload assets" }], - })); - vi.mocked(api.readGlobalSecret).mockResolvedValue({ value: "sensitive-test-value" }); -}); -function mount() { - return render( - new Map(), dedupingInterval: 0, shouldRetryOnError: false }} - > - - - - , - ); -} -describe("global credential browsing", () => { - it("lists metadata without fetching values and clears a revealed value on environment change", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - expect(api.readGlobalSecret).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Reveal" })); - await screen.findByText("sensitive-test-value"); - await user.click(screen.getByRole("combobox", { name: "Browsing environment" })); - await user.click(await screen.findByRole("option", { name: "Production (prod)" })); - await waitFor(() => expect(api.getGlobalListing).toHaveBeenCalledWith("?env=prod&path=%2F")); - expect(screen.queryByText("sensitive-test-value")).toBeNull(); - }); - it("discards a late plaintext response after leaving the page", async () => { - let resolve!: (v: { value: string }) => void; - vi.mocked(api.readGlobalSecret).mockReturnValue( - new Promise((r) => { - resolve = r; - }), - ); - const page = mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "Reveal" })); - page.unmount(); - await act(async () => resolve({ value: "late-secret" })); - expect(screen.queryByText("late-secret")).toBeNull(); - }); - it("shows fetch failures instead of an empty-folder result", async () => { - vi.mocked(api.getGlobalListing).mockRejectedValue(new Error("Permission denied")); - mount(); - await screen.findByRole("alert"); - expect(screen.getByRole("alert").textContent).toContain("Permission denied"); - expect(screen.queryByText("No variables in this folder.")).toBeNull(); - }); -}); - -async function openBinding(user: ReturnType, existing = false) { - await user.click( - await screen.findByRole("button", { - name: i18n.t(existing ? "binding.change" : "binding.globalTitle"), - }), - ); - return within(await screen.findByRole("dialog", { name: i18n.t("binding.globalTitle") })); -} -async function chooseExisting(user: ReturnType) { - await user.click(screen.getByRole("combobox", { name: i18n.t("binding.method") })); - await user.click(await screen.findByRole("option", { name: i18n.t("binding.existing") })); -} -describe("shared credential setup", () => { - it.each(["en-US", "zh-CN"])( - "binds the default storage only after confirmation in %s", - async (locale) => { - await i18n.changeLanguage(locale); - vi.mocked(api.getLocation).mockResolvedValue({ location: null }); - vi.mocked(api.initializeGlobalLocation).mockImplementation(async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location }); - return { location }; - }); - mount(); - const user = userEvent.setup(); - const dialog = await openBinding(user); - expect(api.initializeGlobalLocation).not.toHaveBeenCalled(); - expect(api.getGlobalListing).not.toHaveBeenCalled(); - await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") })); - await screen.findByText("OSS_AK"); - expect(api.initializeGlobalLocation).toHaveBeenCalledTimes(1); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - }, - ); - it("creates and selects a custom project, then binds only on confirmation", async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location: null }); - const created = { - id: "team", - name: "Team", - orgId: "org", - environments: [{ name: "Development", slug: "dev" }], - }; - vi.mocked(api.createRemoteProject).mockResolvedValue(created); - vi.mocked(api.getProject).mockResolvedValue(created); - vi.mocked(api.bindLocation).mockImplementation(async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location }); - return { location }; - }); - mount(); - const user = userEvent.setup(); - await openBinding(user); - await chooseExisting(user); - await user.click(screen.getByRole("button", { name: "New project" })); - await user.type(screen.getByLabelText("Project name"), "Team"); - await user.click(screen.getByRole("button", { name: "Create and select" })); - await waitFor(() => expect(screen.queryByRole("dialog", { name: "New project" })).toBeNull()); - expect(api.createRemoteProject).toHaveBeenCalledWith("Team"); - expect(api.bindLocation).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: i18n.t("binding.bindExisting") })); - await screen.findByText("OSS_AK"); - expect(api.bindLocation).toHaveBeenCalledWith("team", "dev"); - }); - it("keeps a failed creation editable and leaves the existing location alone", async () => { - vi.mocked(api.createRemoteProject).mockRejectedValue( - new Error("No permission to create projects"), - ); - mount(); - const user = userEvent.setup(); - await openBinding(user, true); - await user.click(screen.getByRole("button", { name: "New project" })); - await user.type(screen.getByLabelText("Project name"), "Team"); - await user.click(screen.getByRole("button", { name: "Create and select" })); - await screen.findByText("No permission to create projects"); - expect((screen.getByLabelText("Project name") as HTMLInputElement).value).toBe("Team"); - expect(api.bindLocation).not.toHaveBeenCalled(); - expect(api.initializeGlobalLocation).not.toHaveBeenCalled(); - }); - it("retains default setup failures and retries without an empty credential list", async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location: null }); - vi.mocked(api.initializeGlobalLocation) - .mockRejectedValueOnce(new Error("Default environment is missing")) - .mockImplementationOnce(async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location }); - return { location }; - }); - mount(); - const user = userEvent.setup(); - const dialog = await openBinding(user); - await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") })); - await dialog.findByText("Default environment is missing"); - expect(api.getGlobalListing).not.toHaveBeenCalled(); - await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") })); - await screen.findByText("OSS_AK"); - }); -}); - -describe("credential editor recovery", () => { - it("shows a recoverable search empty state without fetching secret values", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.type(screen.getByRole("textbox", { name: "Search variable names" }), "unmatched"); - expect(screen.getByRole("heading", { name: "No matching variables" })).toBeDefined(); - await user.click(screen.getByRole("button", { name: "Clear search" })); - expect(screen.getByText("OSS_AK")).toBeDefined(); - expect(api.readGlobalSecret).not.toHaveBeenCalled(); - }); - it("submits with Enter, retains failed input, and retries the same value", async () => { - vi.mocked(api.saveGlobalSecret) - .mockRejectedValueOnce(new Error("Write denied")) - .mockResolvedValueOnce(undefined); - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "Add variable" })); - await user.type(screen.getByLabelText("Name"), " NEW_KEY "); - await user.type(screen.getByLabelText("New value"), "test-only{Enter}"); - const dialog = screen.getByRole("dialog"); - expect(await within(dialog).findByText("Write denied")).toBeDefined(); - expect((screen.getByLabelText("New value") as HTMLInputElement).value).toBe("test-only"); - await user.click(within(dialog).getByRole("button", { name: "Save to Infisical" })); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(api.saveGlobalSecret).toHaveBeenNthCalledWith( - 2, - "NEW_KEY", - "test-only", - "?env=dev&path=%2F", - false, - ); - }); - it("keeps edits when Escape is cancelled, then discards without a remote write", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "Add variable" })); - await user.type(screen.getByLabelText("Name"), "DRAFT_KEY"); - await user.keyboard("{Escape}"); - expect(screen.getByRole("alertdialog", { name: "Discard unsaved changes?" })).toBeDefined(); - await user.click(screen.getByRole("button", { name: "Keep editing" })); - expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("DRAFT_KEY"); - await user.click(screen.getByRole("button", { name: "Cancel" })); - await user.click(screen.getByRole("button", { name: "Discard changes" })); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(api.saveGlobalSecret).not.toHaveBeenCalled(); - }); - it("opens edit and delete from the row menu and keeps delete cancel harmless", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "More actions for OSS_AK" })); - await user.click(screen.getByRole("menuitem", { name: "Edit" })); - expect((screen.getByLabelText("Name") as HTMLInputElement).readOnly).toBe(true); - await user.click(screen.getByRole("button", { name: "Cancel" })); - await waitFor(() => - expect(document.activeElement).toBe( - screen.getByRole("button", { name: "More actions for OSS_AK" }), - ), - ); - await user.click(screen.getByRole("button", { name: "More actions for OSS_AK" })); - await user.click(screen.getByRole("menuitem", { name: "Delete" })); - const dialog = screen.getByRole("alertdialog"); - expect(dialog.textContent).toContain("OSS_AK"); - await user.click(within(dialog).getByRole("button", { name: "Cancel" })); - expect(api.deleteGlobalSecret).not.toHaveBeenCalled(); - }); - it("rejects whitespace-only folder names and allows cancelling an empty folder", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "New folder" })); - await user.type(screen.getByLabelText("Folder name"), " "); - expect( - (screen.getByRole("button", { name: "Save to Infisical" }) as HTMLButtonElement).disabled, - ).toBe(true); - await user.clear(screen.getByLabelText("Folder name")); - await user.click(screen.getByRole("button", { name: "Cancel" })); - expect(api.createGlobalFolder).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/dashboard/src/features/global-variables/GlobalVariables.tsx b/apps/dashboard/src/features/global-variables/GlobalVariables.tsx index ae9104a7..557083f8 100644 --- a/apps/dashboard/src/features/global-variables/GlobalVariables.tsx +++ b/apps/dashboard/src/features/global-variables/GlobalVariables.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import useSWR from "swr"; +import useSWR, { useSWRConfig } from "swr"; import { Link } from "react-router-dom"; import { ArrowUp, @@ -16,7 +16,6 @@ import { Plus, RefreshCw, SearchX, - Settings2, ShieldCheck, Trash2, } from "lucide-react"; @@ -24,18 +23,19 @@ import { createGlobalFolder, deleteGlobalSecret, getGlobalListing, - getLocation, getProject, getSession, globalQuery, - locationKey, + globalListingKey, message, + remoteProjectsKey, readGlobalSecret, saveGlobalSecret, sessionKey, type GlobalLocation, } from "@/api/session"; -import { InfisicalBindingDialog } from "@/features/infisical-binding/InfisicalBindingDialog"; +import { FolderTree } from "./FolderTree"; +import { SharedCredentialsNotice } from "@/features/infisical-session/SharedCredentialsNotice"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; @@ -44,7 +44,6 @@ import { Field, FieldLabel } from "@/components/ui/field"; import { IconButton } from "@/components/ui/icon-button"; import { ErrorNotice, - PageHeader, SearchInput, SectionHeading, StatePanel, @@ -96,56 +95,39 @@ import { useToast } from "@/hooks/useToast"; export function GlobalVariables() { const { t } = useTranslation(); const session = useSWR(sessionKey, getSession, { refreshInterval: 2000 }); - const location = useSWR(locationKey, getLocation); - const [configure, setConfigure] = useState(false); - const current = location.data?.location; - const signedIn = session.data?.session.loggedIn; - const mismatched = + const account = session.data?.session; + const preparation = session.data?.sharedCredentials; + const current = preparation?.status === "ready" ? preparation.location : undefined; + const usable = current && - signedIn && - (current.userId !== session.data?.session.userId || - current.siteUrl !== session.data?.session.siteUrl || - (session.data?.session.organizationId && - current.organizationId !== session.data?.session.organizationId)); + account?.loggedIn && + current.userId === account.userId && + current.siteUrl === account.siteUrl && + current.organizationId === account.organizationId; return ( -
    - setConfigure(true)}> - - {t("binding.change")} - - ) : undefined - } - /> - {session.error || location.error ? ( +
    + {session.error ? ( { - void session.mutate(); - void location.mutate(); - }} - > + } > - {message(session.error || location.error)} + {message(session.error)} - ) : session.isLoading || location.isLoading ? ( -
    - - + ) : session.isLoading ? ( +
    + +
    - ) : !signedIn ? ( - + ) : !account?.loggedIn ? ( + + ) : usable ? ( + ) : ( - <> - {mismatched && {t("global.mismatch")}} - {!current || mismatched ? ( - - - - - - ) : ( - - )} - + - + )}
    ); @@ -191,17 +170,16 @@ export function GlobalVariables() { function VariableBrowser({ location }: { location: GlobalLocation }) { const { t } = useTranslation(); const toast = useToast(); + const { mutate } = useSWRConfig(); const [environment, setEnvironment] = useState(location.defaultEnvironment); const [path, setPath] = useState("/"); const [search, setSearch] = useState(""); - const detail = useSWR(`/infisical/projects/${location.projectId}`, () => - getProject(location.projectId), + const detail = useSWR( + remoteProjectsKey({ loggedIn: true, expired: false, ...location }, location.projectId), + () => getProject(location.projectId), ); const query = globalQuery(environment, path); - const listing = useSWR( - `/global-env/secrets:${location.userId}:${location.siteUrl}:${location.projectId}${query}`, - () => getGlobalListing(query), - ); + const listing = useSWR(globalListingKey(location, query), () => getGlobalListing(query)); const [revealed, setRevealed] = useState>({}); const epoch = useRef(0); const pending = useRef(false); @@ -260,7 +238,7 @@ function VariableBrowser({ location }: { location: GlobalLocation }) { if (copy) { await navigator.clipboard.writeText(value); toast.success(t("global.copied")); - } else setRevealed((v) => ({ ...v, [key]: value })); + } else setRevealed((v) => ({ ...v, [`${query}:${key}`]: value })); }); } function openEditor(key = "", existing = false) { @@ -286,12 +264,11 @@ function VariableBrowser({ location }: { location: GlobalLocation }) { const unavailable = busy || editing || listing.isLoading || Boolean(listing.error); return ( <> - -
    + +
    { - setProject(v); - setEnvironment(""); - }} - disabled={busy || projects.isLoading} - > - - - - - {projects.data?.map((p) => ( - - {p.name} - - ))} - - - -
    -
    -
    - - -
    -
    - {projects.data?.length === 0 ? ( -

    {t("global.noProjects")}

    - ) : null} - {error || projects.error || detail.error ? ( - { - void projects.mutate(); - void detail.mutate(); - }} - > - - {t("secrets.retry")} - - ) : undefined - } - > - {error || message(projects.error || detail.error)} - - ) : null} -
    - - {onCancel ? ( - - ) : null} -
    - - - { - if (!open) closeCreation(); - }} - > - - - {t("global.createProject")} - {t("global.createProjectHint")} - -
    { - e.preventDefault(); - if (!busy && name.trim()) void create(); - }} - > -
    - - setName(e.target.value)} - disabled={busy} - placeholder="shared-credentials" - /> -
    - {createError ? ( -

    - {createError} -

    - ) : null} - - - - -
    -
    -
    - { - setCreating(false); - setDiscard(false); - setName(""); - }} - /> - - ); -} diff --git a/apps/dashboard/src/features/infisical-binding/InfisicalBindingDialog.tsx b/apps/dashboard/src/features/infisical-binding/InfisicalBindingDialog.tsx index 4ad3daec..261dc84b 100644 --- a/apps/dashboard/src/features/infisical-binding/InfisicalBindingDialog.tsx +++ b/apps/dashboard/src/features/infisical-binding/InfisicalBindingDialog.tsx @@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next"; import { Link, useSearchParams } from "react-router-dom"; import useSWR, { useSWRConfig } from "swr"; import { + createRemoteProject, getProject, getProjects, getSession, - initializeGlobalLocation, message, + remoteProjectsKey, sessionKey, - type GlobalLocation, + type RemoteProject, } from "@/api/session"; import { bindWorkspaceEnvironment, @@ -38,7 +39,6 @@ import { SelectValue, } from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; -import { LocationPicker } from "@/features/global-variables/LocationPicker"; import { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; import { manifestDraftKey, @@ -50,10 +50,8 @@ import type { HttpError } from "@/types/api"; type Props = { open: boolean; onOpenChange(open: boolean): void; - scope: "global" | "workspace"; workspaceEntryId?: string; environment?: string; - initial?: GlobalLocation; readOnly?: boolean; }; @@ -65,7 +63,7 @@ export function InfisicalBindingDialog(props: Props) { {props.open && ( )} @@ -73,68 +71,63 @@ export function InfisicalBindingDialog(props: Props) { ); } -function BindingForm({ - scope, - workspaceEntryId, - environment, - initial, - readOnly, - onOpenChange, -}: Props) { +function BindingForm({ workspaceEntryId, environment, readOnly, onOpenChange }: Props) { const { t } = useTranslation(); const toast = useToast(); const { mutate } = useSWRConfig(); const [, setSearchParams] = useSearchParams(); const session = useSWR(sessionKey, getSession); - const workspace = scope === "workspace"; - const settings = useSWR(workspace ? workspaceEnvironmentKey(workspaceEntryId) : null, () => + const account = session.data?.session; + const identity = `${account?.siteUrl}:${account?.userId}:${account?.organizationId}`; + const settings = useSWR(workspaceEnvironmentKey(workspaceEntryId), () => getWorkspaceEnvironment(workspaceEntryId), ); - const overview = useSWR(workspace ? overviewKeyFor(workspaceEntryId) : null, () => - getOverview(workspaceEntryId), - ); - const projects = useSWR( - session.data?.session.loggedIn ? "/infisical/projects" : null, - getProjects, - ); + const overview = useSWR(overviewKeyFor(workspaceEntryId), () => getOverview(workspaceEntryId)); + const projects = useSWR(remoteProjectsKey(account), getProjects); const [mode, setMode] = useState("automatic"); const [project, setProject] = useState(""); + const selectedProject = projects.data?.find((p) => p.id === project); const detail = useSWR( - workspace && mode === "existing" && project ? `/infisical/projects/${project}` : null, + mode === "existing" && selectedProject && !projects.error + ? remoteProjectsKey(account, project) + : null, () => getProject(project), ); + const selectedEnvironment = "dev"; const [busy, setBusy] = useState(false); const pending = useRef(false); const active = useRef(true); const [error, setError] = useState(""); const [conflict, setConflict] = useState(false); - const draft = useManifestDraftStore((s) => - workspace ? s.drafts[manifestDraftKey(workspaceEntryId)] : undefined, - ); - const initialId = workspace ? settings.data?.projectId : initial?.projectId; + const draft = useManifestDraftStore((s) => s.drafts[manifestDraftKey(workspaceEntryId)]); + const initialId = settings.data?.projectId; const initialized = useRef(false); useEffect(() => { - if (initialized.current || (workspace && !settings.data)) return; + if (initialized.current || !settings.data) return; initialized.current = true; if (initialId) { setMode("existing"); setProject(initialId); } - }, [workspace, settings.data, initialId]); + }, [settings.data, initialId]); useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); - const account = session.data?.session; - const identity = `${account?.siteUrl}:${account?.userId}:${account?.organizationId}`; const currentIdentity = useRef(identity); currentIdentity.current = identity; - const currentName = - projects.data?.find((p) => p.id === initialId)?.name || initial?.projectName || initialId; - const targetName = workspace ? overview.data?.workspace?.name : "shared-credentials"; + const currentName = projects.data?.find((p) => p.id === initialId)?.name || initialId; + const [creationSuffix] = useState(() => crypto.randomUUID().slice(0, 4)); + const workspaceName = overview.data?.workspace?.name; + const targetName = + initialId && workspaceName + ? `${Array.from(workspaceName).slice(0, 59).join("")}-${creationSuffix}` + : workspaceName; const isExisting = mode === "existing"; + const unavailableBinding = + initialId && projects.data && !projects.error && !projects.data.some((p) => p.id === initialId); const canBind = !readOnly && !busy && @@ -142,10 +135,17 @@ function BindingForm({ !conflict && account?.loggedIn && account.organizationId && - (!workspace || (settings.data && overview.data && !settings.error && !overview.error)) && + settings.data && + overview.data && + !settings.error && + !overview.error && + (isExisting || !!targetName) && (!isExisting || - !workspace || - (detail.data?.environments.some((e) => e.slug === "dev") && !detail.error)); + (selectedProject && + !projects.error && + detail.data?.id === project && + detail.data.environments.some((e) => e.slug === selectedEnvironment) && + !detail.error)); async function finish(name: string) { if (!active.current) return; @@ -154,12 +154,9 @@ function BindingForm({ await Promise.allSettled([ mutate( (key) => - typeof key === "string" && - (workspace - ? key.startsWith(`${workspaceBasePath(workspaceEntryId)}/`) - : key === "/global-env/location"), + typeof key === "string" && key.startsWith(`${workspaceBasePath(workspaceEntryId)}/`), ), - mutate("/infisical/projects"), + projects.mutate(), ]); } async function bind() { @@ -168,31 +165,49 @@ function BindingForm({ setBusy(true); setError(""); const submittedIdentity = identity; + let createdProject: RemoteProject | undefined; try { - if (workspace) { - const result = await bindWorkspaceEnvironment(workspaceEntryId, { - revision: settings.data!.revision, - create: !isExisting, - ...(isExisting ? { projectId: project } : {}), + // The workspace initializer preserves existing bindings. Create explicitly + // before selecting the replacement, and retain it if saving fails. + if (!isExisting && initialId) { + createdProject = await createRemoteProject(targetName!); + if (!active.current || submittedIdentity !== currentIdentity.current) return; + await mutate(remoteProjectsKey(account, createdProject.id)!, createdProject, { + revalidate: false, }); - if (active.current && submittedIdentity === currentIdentity.current) { - if (environment && !result.environments.includes(environment)) { - setSearchParams((current) => { - const next = new URLSearchParams(current); - next.set("env", "dev"); - return next; - }); - } - await finish(result.project_name || result.project_id); + const created = createdProject; + await projects.mutate( + (current) => [...(current ?? []).filter((p) => p.id !== created.id), created], + { revalidate: false }, + ); + } + const result = await bindWorkspaceEnvironment(workspaceEntryId, { + revision: settings.data!.revision, + create: !isExisting && !createdProject, + ...(createdProject + ? { projectId: createdProject.id } + : isExisting + ? { projectId: project } + : {}), + }); + if (active.current && submittedIdentity === currentIdentity.current) { + if (environment && !result.environments.includes(environment)) { + setSearchParams((current) => { + const next = new URLSearchParams(current); + next.set("env", "dev"); + return next; + }); } - } else { - const result = await initializeGlobalLocation(); - if (submittedIdentity === currentIdentity.current) - await finish(result.location.projectName); + await finish(result.project_name || result.project_id); } } catch (cause) { if (!active.current || submittedIdentity !== currentIdentity.current) return; const failure = cause as HttpError; + if (createdProject) { + setMode("existing"); + setProject(createdProject.id); + void projects.mutate().catch(() => undefined); + } if ( failure.context?.partial_state === "project_created_binding_unsaved" && typeof failure.context.project_id === "string" @@ -202,7 +217,17 @@ function BindingForm({ void projects.mutate().catch(() => undefined); } setConflict(failure.code === "SERVE_MANIFEST_CONFLICT"); - setError(failure.code === "SERVE_MANIFEST_CONFLICT" ? t("binding.conflict") : message(cause)); + const reason = + failure.code === "SERVE_MANIFEST_CONFLICT" ? t("binding.conflict") : message(cause); + setError( + createdProject + ? t("binding.createdUnsaved", { + name: createdProject.name, + id: createdProject.id, + reason, + }) + : reason, + ); } finally { pending.current = false; if (active.current) setBusy(false); @@ -219,10 +244,8 @@ function BindingForm({ }} > - {t(workspace ? "binding.workspaceTitle" : "binding.globalTitle")} - - {t(workspace ? "binding.workspaceHint" : "binding.globalHint")} - + {t(initialId ? "binding.change" : "binding.workspaceTitle")} + {t("binding.workspaceHint")} {session.error ? ( {message(session.error)} @@ -260,7 +283,7 @@ function BindingForm({
    - {isExisting && !workspace ? ( - { - await finish(location?.projectName || t("global.title")); - }} - onCancel={() => onOpenChange(false)} - onBusyChange={setBusy} - /> - ) : ( - <> - {isExisting ? ( -
    - - - {detail.data && !detail.data.environments.some((e) => e.slug === "dev") && ( - {t("binding.devRequired")} - )} - {projects.data?.length === 0 && ( -

    {t("binding.noProjects")}

    - )} -
    - ) : null} -
    -

    - {t("binding.target", { - name: isExisting - ? detail.data?.name || project - : targetName || t("session.loading"), - })} -

    -

    {t("binding.defaultEnvironment")}

    -

    - {t(workspace ? "binding.workspaceStorage" : "binding.globalStorage")} + {isExisting ? ( +

    + + + {unavailableBinding && (!project || project === initialId) && ( + {t("binding.unavailable")} + )} + {detail.isLoading && ( +

    + {t("session.loading")}

    -
    - {settings.error || - overview.error || - (isExisting && (projects.error || detail.error)) ? ( - - {message(settings.error || overview.error || projects.error || detail.error)} - - ) : null} - {draft && ( -
    -

    {t("binding.pendingDraft")}

    -
    - {workspaceEntryId && } - -
    -
    )} - {error && ( - { - await Promise.allSettled([settings.mutate(), overview.mutate()]); - setConflict(false); - setError(""); - }} - > - {t("secrets.retry")} - - ) : undefined - } - > - {error} - + {detail.data && !detail.data.environments.some((e) => e.slug === "dev") && ( + {t("binding.devRequired")} )} - - -
    + ) : null} +
    +

    + {t("binding.target", { + name: isExisting + ? detail.data?.name || selectedProject?.name || t("global.selectProject") + : targetName || t("session.loading"), + })} +

    + {initialId && (!isExisting || project !== initialId) && ( +

    {t("binding.replaceHint")}

    + )} +

    {t("binding.defaultEnvironment")}

    +

    {t("binding.workspaceStorage")}

    +
    + {settings.error || overview.error || (isExisting && (projects.error || detail.error)) ? ( + + {message(settings.error || overview.error || projects.error || detail.error)} + + ) : null} + {draft && ( +
    +

    {t("binding.pendingDraft")}

    +
    + {workspaceEntryId && } + - - +
    +
    )} + {error && ( + { + await Promise.allSettled([settings.mutate(), overview.mutate()]); + setConflict(false); + setError(""); + }} + > + {t("secrets.retry")} + + ) : undefined + } + > + {error} + + )} + + + + )} diff --git a/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx b/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx deleted file mode 100644 index 4cc33aa8..00000000 --- a/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -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 { 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(), - startLogin: vi.fn(), - cancelLogin: vi.fn(), - logout: vi.fn(), -})); -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 }} - > - - - - - - , - ); -} -describe("account state and recovery", () => { - it("shows only a loading state before the session resolves", async () => { - let finish!: (value: api.SessionState) => void; - vi.mocked(api.getSession).mockReturnValue( - new Promise((resolve) => { - finish = resolve; - }), - ); - mount(); - expect(screen.getByRole("status")).toBeDefined(); - expect(screen.queryByRole("button", { name: "Sign in with browser" })).toBeNull(); - await act(async () => - finish({ session: { loggedIn: true, expired: false, email: "demo@example.com" } }), - ); - expect(await screen.findByText("Connected")).toBeDefined(); - expect(screen.getByText("demo@example.com")).toBeDefined(); - }); - it("offers retry on session failure and recovers to the signed-out state", async () => { - vi.mocked(api.getSession) - .mockRejectedValueOnce(new Error("Session unavailable")) - .mockResolvedValue({ session: { loggedIn: false, expired: false } }); - mount(); - const user = userEvent.setup(); - expect(await screen.findByText("Session unavailable")).toBeDefined(); - await user.click(screen.getByRole("button", { name: "Retry" })); - expect(await screen.findByRole("button", { name: "Sign in with browser" })).toBeDefined(); - }); - it("keeps the waiting login visible with reopen and cancel actions", async () => { - vi.mocked(api.getSession).mockResolvedValue({ - session: { loggedIn: false, expired: false }, - login: { status: "waiting", url: "https://app.infisical.com/test-login" }, - }); - mount(); - expect(await screen.findByRole("link", { name: "Reopen login page" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); - 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 a5821713..1a2725cb 100644 --- a/apps/dashboard/src/features/infisical-session/AccountSettings.tsx +++ b/apps/dashboard/src/features/infisical-session/AccountSettings.tsx @@ -1,6 +1,5 @@ import { ExternalLink, - KeyRound, Languages, LogIn, LogOut, @@ -10,7 +9,7 @@ import { Settings2, ShieldCheck, } from "lucide-react"; -import { Link } from "react-router-dom"; +import { SharedCredentialsNotice } from "./SharedCredentialsNotice"; import { Badge } from "@/components/ui/badge"; import { ErrorNotice, PageHeader, SectionHeading } from "@/components/ui/page-layout"; import { Skeleton } from "@/components/ui/skeleton"; @@ -109,23 +108,35 @@ export function AccountSettings() {

    -
    - + +
    + } + > +

    {t("global.preparationFailed")}

    +

    {error || state.error}

    + + ); +} diff --git a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx deleted file mode 100644 index 2a84276e..00000000 --- a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx +++ /dev/null @@ -1,312 +0,0 @@ -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: "electron-app", - name: "Electron", - description: "Electron", - category: "frontend", - directory: "apps", - toolchain: "node", - projects: [ - { directory: "apps", suffix: "-renderer" }, - { directory: "services", suffix: "-main" }, - { directory: "packages", suffix: "-preload" }, - ], - }, - { - 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"])("can skip development skills in %s", async (locale) => { - await i18n.changeLanguage(locale); - const user = userEvent.setup(); - let payload: unknown; - server.use( - http.post("http://localhost/api/workspaces/:entryId/projects", async ({ request }) => { - payload = await request.json(); - return HttpResponse.json({ name: "web", relativeDir: "apps/web", templateId: "react-spa" }); - }), - ); - const { onCreated } = show(); - await user.type(screen.getByLabelText(i18n.t("projectCreate.name")), "web"); - const toggle = screen.getByRole("switch", { name: i18n.t("projectCreate.installSkills") }); - expect(toggle.getAttribute("aria-checked")).toBe("true"); - await user.click(toggle); - await waitFor(() => - expect( - screen - .getByRole("button", { name: i18n.t("projectCreate.submit") }) - .hasAttribute("disabled"), - ).toBe(false), - ); - await user.click(screen.getByRole("button", { name: i18n.t("projectCreate.submit") })); - await waitFor(() => expect(onCreated).toHaveBeenCalledWith("web")); - expect(payload).toEqual({ name: "web", templateId: "react-spa", skipSkills: true }); - }); - 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.each(["en-US", "zh-CN"])("previews and creates all Electron members in %s", async (locale) => { - await i18n.changeLanguage(locale); - const user = userEvent.setup(); - server.use( - http.post("http://localhost/api/workspaces/:entryId/projects", async ({ request }) => { - expect(await request.json()).toEqual({ name: "desktop", templateId: "electron-app" }); - return HttpResponse.json( - { - name: "desktop", - relativeDir: ".", - templateId: "electron-app", - projects: [ - { - name: "desktop-renderer", - relativeDir: "apps/desktop-renderer", - templateId: "electron-app", - }, - { - name: "desktop-main", - relativeDir: "services/desktop-main", - templateId: "electron-app", - }, - { - name: "desktop-preload", - relativeDir: "packages/desktop-preload", - templateId: "electron-app", - }, - ], - }, - { status: 201 }, - ); - }), - ); - const { onCreated } = show(); - await user.type(screen.getByLabelText(i18n.t("projectCreate.name")), "desktop"); - 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.electron-app.name"), - }), - ); - for (const dir of [ - "apps/desktop-renderer", - "services/desktop-main", - "packages/desktop-preload", - ]) - expect(screen.getByText(dir)).toBeDefined(); - await user.click(screen.getByRole("button", { name: i18n.t("projectCreate.submit") })); - await waitFor(() => expect(onCreated).toHaveBeenCalledWith("desktop-renderer")); - }); - - it("rejects a conflicting Electron member before creation", async () => { - const user = userEvent.setup(); - const post = vi.fn(); - server.use(http.post("http://localhost/api/workspaces/:entryId/projects", post)); - show(["desktop-main"]); - await user.type(screen.getByLabelText("Project name"), "desktop"); - 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.electron-app.name"), - }), - ); - await user.click(screen.getByRole("button", { name: "Create project" })); - expect(screen.getByRole("alert")).toBeDefined(); - expect(post).not.toHaveBeenCalled(); - }); - - 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-settings/ProjectInspector.tsx b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx index 043d37f8..73670960 100644 --- a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx +++ b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx @@ -4,21 +4,26 @@ import { } from "@/features/manifest-draft/manifest-draft-store"; import { Plus, - Terminal, Server, - Code2, - FileKey2, - Library, - Search, - LayoutGrid, + AppWindow, + Package, LockKeyhole, + KeyRound, + ChevronRight, + type LucideIcon, } from "lucide-react"; +import { Collapsible } from "radix-ui"; import type React from "react"; import { useEffect, useId, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; import useSWR from "swr"; -import { getServices, servicesKey } from "@/api/services"; -import { getProjectSettings, projectSettingsKey } from "@/api/workspace"; +import { + getProjectSettings, + getWorkspaceEnvironment, + projectSettingsKey, + workspaceEnvironmentKey, +} 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"; @@ -34,7 +39,6 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; import { Select, SelectContent, @@ -43,55 +47,61 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -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 { WorkspaceSettingsDialog } from "@/features/workspace-settings/WorkspaceSettingsDialog"; +import { InfisicalBindingDialog } from "@/features/infisical-binding/InfisicalBindingDialog"; import { cn } from "@/lib/utils"; -import type { OverviewProject, ProjectSettingsResponse } from "@/types/api"; - -type ProjectInspectorTab = "overview" | "environment" | "runtime"; +import type { OverviewProject } from "@/types/api"; interface ProjectInspectorProps { environments?: string[]; projects: OverviewProject[]; - currentBackend?: string; environment: string; workspaceEntryId?: string; readOnly?: boolean; } -const TAB_ITEMS: ReadonlyArray<{ - id: ProjectInspectorTab; - icon: React.ComponentType<{ className?: string }>; -}> = [ - { id: "overview", icon: LayoutGrid }, - { id: "runtime", icon: Terminal }, - { id: "environment", icon: FileKey2 }, -]; +const PROJECT_GROUPS = [ + { kind: "app", directory: "apps" }, + { kind: "package", directory: "packages" }, + { kind: "service", directory: "services" }, +] as const; export const ProjectInspector: React.FC = ({ environments, projects, - currentBackend, environment, workspaceEntryId, readOnly, }) => { const { t } = useTranslation(); const dirtyOwner = useId(); - const services = useSWR(servicesKey(workspaceEntryId), () => getServices(workspaceEntryId), { - refreshInterval: 2000, - }); - const [query, setQuery] = useState(""); + const [collapsedGroups, setCollapsedGroups] = useState< + Partial> + >({}); const [createOpen, setCreateOpen] = useState(false); + const [bindingOpen, setBindingOpen] = useState(false); + const binding = useSWR(workspaceEnvironmentKey(workspaceEntryId), () => + getWorkspaceEnvironment(workspaceEntryId), + ); const canCreate = !!workspaceEntryId && !readOnly; const [dirty, setDirty] = useState(false); const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); - const [selectedName, setSelectedName] = useState(projects[0]?.name ?? ""); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedName = searchParams.get("project") ?? projects[0]?.name ?? ""; + function setSelectedName(name: string) { + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + next.set("project", name); + next.delete("tab"); + return next; + }, + { replace: true }, + ); + } const setEnvironmentDirty = useEnvironmentDirtyStore((state) => state.setDirty); const clearEnvironmentDirty = useEnvironmentDirtyStore((state) => state.clearOwner); const selectedProject = projects.find((project) => project.name === selectedName) ?? projects[0]; @@ -116,11 +126,6 @@ export const ProjectInspector: React.FC = ({ setPendingAction(() => action); } - const filteredProjects = projects.filter((project) => - `${project.name} ${project.relativeDir} ${project.domains?.env ?? currentBackend ?? ""}` - .toLocaleLowerCase() - .includes(query.trim().toLocaleLowerCase()), - ); function selectProject(name: string) { if (name === selectedProject?.name) return; requestDiscard(() => { @@ -136,88 +141,81 @@ export const ProjectInspector: React.FC = ({ aria-label={t("projectInspector.workspaceTitle")} className="flex h-full min-h-0 flex-col overflow-hidden bg-background ud-md:grid ud-md:grid-cols-[208px_minmax(0,1fr)] ud-lg:grid-cols-[240px_minmax(0,1fr)]" > -