diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..cb17ba05 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.venv +.github +__pycache__ +*.pyc +media +tests +2-0-TODO.md +*.egg-info +dist +build +.mypy_cache +.pytest_cache diff --git a/.github/workflows/attach-to-release.yml b/.github/workflows/attach-to-release.yml index 7721909b..643c1dc9 100644 --- a/.github/workflows/attach-to-release.yml +++ b/.github/workflows/attach-to-release.yml @@ -1,4 +1,4 @@ -name: Attach Assets to Release +name: "Attach Assets to Release" on: workflow_call: @@ -10,17 +10,23 @@ on: jobs: attach-to-release: name: Attach Assets to Release - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - - name: Download processed assets + - name: Download binaries uses: actions/download-artifact@v4 with: - name: phase-cli-release - path: ./phase-cli-release + name: phase-cli-binaries + path: ./release + + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: phase-cli-packages + path: ./release - name: Attach assets to release uses: softprops/action-gh-release@v2 with: - files: ./phase-cli-release/* + files: ./release/* env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8abea8a..9e6681e9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build CLI +name: "Cross-compile" on: workflow_call: @@ -6,240 +6,38 @@ on: version: required: true type: string - python_version: - required: true - type: string - alpine_version: - required: true - type: string jobs: build: - name: Build CLI - runs-on: ${{ matrix.os }} - strategy: - matrix: - # ubuntu-22.04 - context: https://github.com/phasehq/cli/issues/94 - # macos-15-intel darwin-amd64 builds (intel) - # macos-14 darwin-arm64 builds (apple silicon) - # context: https://github.com/actions/runner-images?tab=readme-ov-file#available-images - - os: [ubuntu-22.04, windows-2022, macos-15-intel, macos-14] + name: Cross-compile all targets + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python_version }} - - run: pip install -r requirements.txt - if: runner.os != 'Linux' - - run: pip install pyinstaller==6.16.0 - if: runner.os != 'Linux' - - run: pyinstaller --hidden-import _cffi_backend --paths ./phase_cli --name phase phase_cli/main.py - if: runner.os != 'Linux' - - name: Build Linux binary in manylinux_2_28 container (glibc 2.28 baseline) - if: matrix.os == 'ubuntu-22.04' - # Design decisions: - # - Use manylinux_2_28 for Linux builds to target glibc 2.28 baseline while maintaining wide compatibility - # - Build CPython in-container with --enable-shared (shared libpython) so PyInstaller can bundle correctly. - # - Use system OpenSSL from manylinux_2_28; avoid building OpenSSL from source for speed and simplicity. - # - Install Python from source with --enable-shared to ensure a shared libpython is available for PyInstaller. - # - Package PyInstaller onedir under /usr/lib/phase and symlink /usr/bin/phase to preserve metadata and bundled files (avoids runtime import errors on RPM-based distros). - # - Print ldd --version - run: | - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - quay.io/pypa/manylinux_2_28_x86_64 \ - /bin/bash -lc 'set -euo pipefail; \ - echo "=== ldd version (manylinux_2_28 x86_64) ==="; \ - ldd --version; \ - echo "=== Installing build deps ==="; \ - yum -y install wget tar make gcc openssl-devel bzip2-devel libffi-devel zlib-devel >/dev/null; \ - echo "Install Python from source with --enable-shared to ensure a shared libpython is available for PyInstaller."; \ - echo "=== Building Python ${{ inputs.python_version }} with --enable-shared ==="; \ - PYVER="${{ inputs.python_version }}"; \ - MAJOR=$(echo "$PYVER" | cut -d. -f1); \ - MINOR=$(echo "$PYVER" | cut -d. -f2); \ - FULLVER=$(curl -s https://www.python.org/ftp/python/ | grep -oE ">${MAJOR}\\.${MINOR}\\.[0-9]+/" | tr -d ">/" | sort -V | tail -1 || echo "$PYVER"); \ - cd /tmp; \ - wget -q https://www.python.org/ftp/python/${FULLVER}/Python-${FULLVER}.tgz; \ - tar -xzf Python-${FULLVER}.tgz; \ - cd Python-${FULLVER}; \ - ./configure --prefix=/opt/py-shared --enable-shared --with-system-openssl >/dev/null; \ - make -j$(nproc) >/dev/null; \ - make install >/dev/null; \ - export LD_LIBRARY_PATH=/opt/py-shared/lib:$LD_LIBRARY_PATH; \ - /opt/py-shared/bin/python3 --version; \ - /opt/py-shared/bin/python3 -c '\''import ssl; print("SSL OK")'\''; \ - /opt/py-shared/bin/python3 -m pip install --upgrade pip >/dev/null; \ - cd /workspace; \ - /opt/py-shared/bin/python3 -m pip install -r requirements.txt >/dev/null; \ - /opt/py-shared/bin/python3 -m pip install pyinstaller==6.16.0 >/dev/null; \ - /opt/py-shared/bin/python3 -m PyInstaller --hidden-import _cffi_backend --paths ./phase_cli --name phase phase_cli/main.py' - shell: bash - - - name: Codesign macOS build output - if: runner.os == 'macOS' - run: | - uname -a - codesign --force --deep --sign - dist/phase/phase - codesign --verify --deep --verbose=2 dist/phase/phase - - name: Print GLIBC version - if: matrix.os == 'ubuntu-22.04' - run: ldd --version - - # Set LC_ALL based on the runner OS for Linux and macOS - - name: Set LC_ALL for Linux and macOS - run: export LC_ALL=C.UTF-8 - if: runner.os != 'Windows' - shell: bash - - # Set LC_ALL for Windows - - name: Set LC_ALL for Windows - run: echo "LC_ALL=C.UTF-8" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV - if: runner.os == 'Windows' - shell: pwsh - - # Build DEB and RPM packages for Linux - - run: | - sudo apt-get update - sudo apt-get install -y ruby-dev rubygems build-essential - sudo gem install --no-document fpm - # Stage files to preserve PyInstaller onedir layout - rm -rf pkgroot - mkdir -p pkgroot/usr/lib/phase - cp -a dist/phase/. pkgroot/usr/lib/phase/ - mkdir -p pkgroot/usr/bin - echo "Symlink ensures PATH-discoverable binary while keeping full onedir intact" - # This will create a symlink to the phase binary in the /usr/lib/phase/ directory - ln -sf ../lib/phase/phase pkgroot/usr/bin/phase - # Build packages from staged root - fpm -s dir -t deb -n phase -v ${{ inputs.version }} -C pkgroot . - fpm -s dir -t rpm -n phase -v ${{ inputs.version }} -C pkgroot . - if: matrix.os == 'ubuntu-22.04' - shell: bash - # Upload DEB and RPM packages - - uses: actions/upload-artifact@v4 + - name: Set up Go + uses: actions/setup-go@v5 with: - name: phase-deb - path: "*.deb" - if: matrix.os == 'ubuntu-22.04' - - uses: actions/upload-artifact@v4 - with: - name: phase-rpm - path: "*.rpm" - if: matrix.os == 'ubuntu-22.04' - - - name: Set artifact name - run: | - if [[ "${{ matrix.os }}" == "macos-14" ]]; then - echo "ARTIFACT_NAME=${{ runner.os }}-arm64-binary" >> $GITHUB_ENV - elif [[ "${{ matrix.os }}" == "macos-15-intel" ]]; then - echo "ARTIFACT_NAME=${{ runner.os }}-amd64-binary" >> $GITHUB_ENV - else - echo "ARTIFACT_NAME=${{ runner.os }}-binary" >> $GITHUB_ENV - fi - shell: bash + go-version: "1.24" + cache-dependency-path: src/go.sum - - name: Upload binary - uses: actions/upload-artifact@v4 + - name: Clone Go SDK + uses: actions/checkout@v4 with: - name: ${{ env.ARTIFACT_NAME }} - path: dist/phase* + repository: phasehq/golang-sdk + path: golang-sdk - build_arm: - name: Build Linux ARM64 - runs-on: ubuntu-22.04-arm - steps: - - uses: actions/checkout@v4 - - name: Build with PyInstaller (manylinux_2_28 aarch64 via docker) - run: | - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - quay.io/pypa/manylinux_2_28_aarch64 \ - /bin/bash -lc 'set -euo pipefail; \ - echo "=== ldd version (manylinux_2_28 aarch64) ==="; \ - ldd --version; \ - echo "=== Installing build deps ==="; \ - yum -y install wget tar make gcc openssl-devel bzip2-devel libffi-devel zlib-devel >/dev/null; \ - echo "Install Python from source with --enable-shared to ensure a shared libpython is available for PyInstaller."; \ - echo "=== Building Python ${{ inputs.python_version }} with --enable-shared ==="; \ - PYVER="${{ inputs.python_version }}"; \ - MAJOR=$(echo "$PYVER" | cut -d. -f1); \ - MINOR=$(echo "$PYVER" | cut -d. -f2); \ - FULLVER=$(curl -s https://www.python.org/ftp/python/ | grep -oE ">${MAJOR}\\.${MINOR}\\.[0-9]+/" | tr -d ">/" | sort -V | tail -1 || echo "$PYVER"); \ - cd /tmp; \ - wget -q https://www.python.org/ftp/python/${FULLVER}/Python-${FULLVER}.tgz; \ - tar -xzf Python-${FULLVER}.tgz; \ - cd Python-${FULLVER}; \ - ./configure --prefix=/opt/py-shared --enable-shared --with-system-openssl >/dev/null; \ - make -j$(nproc) >/dev/null; \ - make install >/dev/null; \ - export LD_LIBRARY_PATH=/opt/py-shared/lib:$LD_LIBRARY_PATH; \ - /opt/py-shared/bin/python3 --version; \ - /opt/py-shared/bin/python3 -c '\''import ssl; print("SSL OK")'\''; \ - /opt/py-shared/bin/python3 -m pip install --upgrade pip >/dev/null; \ - cd /workspace; \ - /opt/py-shared/bin/python3 -m pip install -r requirements.txt >/dev/null; \ - /opt/py-shared/bin/python3 -m pip install pyinstaller==6.16.0 >/dev/null; \ - /opt/py-shared/bin/python3 -m PyInstaller --hidden-import _cffi_backend --paths ./phase_cli --name phase phase_cli/main.py' - - uses: actions/upload-artifact@v4 - with: - name: Linux-binary-arm64 - path: dist/phase* + - name: Patch go.mod replace directive for CI + working-directory: src + run: go mod edit -replace github.com/phasehq/golang-sdk=../golang-sdk - build_apk: - name: Build Alpine - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - { os: ubuntu-22.04, arch: x86_64 } - - { os: ubuntu-22.04-arm, arch: aarch64 } - steps: - - uses: actions/checkout@v4 - - name: Build Alpine package + - name: Build all targets run: | - if [ "${{ matrix.arch }}" = "aarch64" ]; then - TARGET_ARCH="arm64" - elif [ "${{ matrix.arch }}" = "x86_64" ]; then - TARGET_ARCH="amd64" - fi - - # CONTEXT: phase_cli_linux__alpine_ artifact will contain phase_cli_linux__.apk - - OUTPUT_PACKAGE_NAME="phase_cli_linux_${TARGET_ARCH}_${{ inputs.version }}.apk" - ARTIFACT_NAME="phase_cli_linux_${TARGET_ARCH}_alpine_${{ inputs.version }}" - - echo "OUTPUT_PACKAGE_NAME=$OUTPUT_PACKAGE_NAME" >> $GITHUB_ENV - echo "ARTIFACT_NAME=$ARTIFACT_NAME" >> $GITHUB_ENV + VERSION="${{ inputs.version }}" OUTPUT_DIR=dist \ + bash scripts/build.sh - mkdir -p ./output - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - --env ABUILD_USER=builder \ - --env ARCH=${{ matrix.arch }} \ - --env OUTPUT_PACKAGE_NAME="$OUTPUT_PACKAGE_NAME" \ - alpine:${{ inputs.alpine_version }} \ - /bin/sh -ec " \ - set -ex; \ - apk update; \ - apk add --no-cache alpine-sdk python3 python3-dev py3-pip build-base git curl sudo doas; \ - adduser -D builder; \ - addgroup builder abuild; \ - echo 'builder ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers; \ - echo 'permit nopass builder as root' >> /etc/doas.conf; \ - chown -R builder /workspace; \ - sudo -u builder /bin/sh -ec 'cd /workspace && export HOME=/home/builder && abuild-keygen -a -i -n'; \ - sudo -u builder /bin/sh -ec 'cd /workspace && abuild -r'; \ - SOURCE_APK=\$(find /home/builder/packages/\$ARCH -name 'phase-*.apk' -print -quit); \ - cp \"\$SOURCE_APK\" \"/workspace/output/\$OUTPUT_PACKAGE_NAME\" \ - " - - name: Upload APK artifacts + - name: Upload binaries uses: actions/upload-artifact@v4 with: - name: ${{ env.ARTIFACT_NAME }} - path: ./output/${{ env.OUTPUT_PACKAGE_NAME }} + name: phase-cli-binaries + path: dist/ + retention-days: 7 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7c402204..46d19077 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,4 +1,4 @@ -name: Docker Build, Push, and Test +name: "Docker Build, Push, and Test" on: workflow_call: @@ -14,71 +14,73 @@ on: jobs: build_push: - name: Build & Release - Docker - runs-on: ubuntu-24.04 + name: Build & Push Docker Image + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Clone Go SDK + uses: actions/checkout@v4 + with: + repository: phasehq/golang-sdk + path: golang-sdk + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Build and push - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: context: . + file: Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: | phasehq/cli:${{ inputs.version }} phasehq/cli:latest + build-args: | + VERSION=${{ inputs.version }} pull_test: - name: Test - CLI - Docker + name: Test Docker Image needs: build_push - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - name: Pull versioned image run: docker pull phasehq/cli:${{ inputs.version }} - - name: Test versioned image version + - name: Test versioned image run: | - echo "Testing versioned image: phasehq/cli:${{ inputs.version }}" + echo "Testing phasehq/cli:${{ inputs.version }}" FULL_OUTPUT=$(docker run --rm phasehq/cli:${{ inputs.version }} --version) - echo "Full output: $FULL_OUTPUT" + echo "Output: $FULL_OUTPUT" RETURNED_VERSION=$(echo "$FULL_OUTPUT" | awk '{print $1}') - echo "Parsed version: $RETURNED_VERSION" - if [ -z "$RETURNED_VERSION" ]; then - echo "Error: Could not parse version from output" - exit 1 - fi if [ "$RETURNED_VERSION" != "${{ inputs.version }}" ]; then echo "Version mismatch: Expected ${{ inputs.version }}, got $RETURNED_VERSION" exit 1 fi - echo "Version check passed for versioned image" + echo "Version check passed" - name: Pull latest image run: docker pull phasehq/cli:latest - - name: Test latest image version + - name: Test latest image run: | - echo "Testing latest image: phasehq/cli:latest" + echo "Testing phasehq/cli:latest" FULL_OUTPUT=$(docker run --rm phasehq/cli:latest --version) - echo "Full output: $FULL_OUTPUT" + echo "Output: $FULL_OUTPUT" RETURNED_VERSION=$(echo "$FULL_OUTPUT" | awk '{print $1}') - echo "Parsed version: $RETURNED_VERSION" - if [ -z "$RETURNED_VERSION" ]; then - echo "Error: Could not parse version from output" - exit 1 - fi if [ "$RETURNED_VERSION" != "${{ inputs.version }}" ]; then echo "Version mismatch: Expected ${{ inputs.version }}, got $RETURNED_VERSION" exit 1 fi - echo "Version check passed for latest image" \ No newline at end of file + echo "Version check passed" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 492e3fc0..7340281f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Test, Build, Package the Phase CLI +name: "Test, Build, Package the Phase CLI" on: pull_request: @@ -14,77 +14,58 @@ permissions: jobs: - # Run Tests - pytest: - uses: ./.github/workflows/pytest.yml - with: - python_version: '3.12' + # Run Go tests and vet + test: + uses: ./.github/workflows/test.yml - # Fetch and validate version from source code + # Extract version from Go source version: uses: ./.github/workflows/version.yml - # Build and package the CLI using PyInstaller for Windows(amd64), Mac (Intel - amd64, Apple silicon arm64), Alpine linux (amd64), Linux (amd64, arm64) .deb, .rpm, binaries - - # TODO: Add arm64 support for windows, arm64 packages (deb, rpm, apk) + # Cross-compile for all platforms build: - needs: [pytest, version] + needs: [test, version] uses: ./.github/workflows/build.yml with: - python_version: '3.12' version: ${{ needs.version.outputs.version }} - alpine_version: '3.21' - # Build docker image, push it to :latest and : OS/Arch linux/amd64, linux/arm64, pull images, run the CLI and validate version - docker: - if: github.event_name == 'release' && github.event.action == 'created' - needs: [version] - uses: ./.github/workflows/docker.yml + # Package binaries into .deb, .rpm, .apk + package: + needs: [build, version] + uses: ./.github/workflows/package.yml with: version: ${{ needs.version.outputs.version }} - secrets: - DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} - DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} - # Build, package and upload to pypi, install, run the cli and validate version - pypi: - if: github.event_name == 'release' && github.event.action == 'created' - needs: [version] - uses: ./.github/workflows/pypi.yml - with: - version: ${{ needs.version.outputs.version }} - secrets: - PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} - PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - - # Download packages, builds, binaries from build stage, rename, hash and zip the assets via the script - process-assets: - needs: [build, version] - uses: ./.github/workflows/process-assets.yml + # Test binaries and packages on Linux distros + test-install: + needs: [build, package, version] + uses: ./.github/workflows/test-install-post-build.yml with: version: ${{ needs.version.outputs.version }} - # Download packaged assets zip and attach them to a release as assets + # Attach release assets to GitHub release attach-to-release: if: github.event_name == 'release' && github.event.action == 'created' - needs: [process-assets, version] + needs: [build, package, version] uses: ./.github/workflows/attach-to-release.yml with: version: ${{ needs.version.outputs.version }} - # Install, run and validate CLI version on various Linux distros - test-install-post-build: - needs: [build, version] - uses: ./.github/workflows/test-install-post-build.yml + # Build and push Docker image + docker: + if: github.event_name == 'release' && github.event.action == 'created' + needs: [version] + uses: ./.github/workflows/docker.yml with: version: ${{ needs.version.outputs.version }} + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} - # Install, run and validate CLI version using installer script - test-cli-install-post-release: - needs: [version, attach-to-release] - if: github.event_name != 'release' || (github.event_name == 'release' && github.event.action == 'created') - uses: ./.github/workflows/test-cli-install-post-release.yml + # Test install.sh script end-to-end after release assets are published + test-install-script: + if: github.event_name == 'release' && github.event.action == 'created' + needs: [attach-to-release, version] + uses: ./.github/workflows/test-install-script.yml with: version: ${{ needs.version.outputs.version }} - python_version: '3.12' - alpine_version: '3.21' \ No newline at end of file diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 00000000..f2429375 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,39 @@ +name: "Package (deb/rpm/apk)" + +on: + workflow_call: + inputs: + version: + required: true + type: string + +jobs: + package: + name: Build packages (deb/rpm/apk) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install FPM + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq ruby ruby-dev build-essential > /dev/null + sudo gem install fpm --no-document + + - name: Download binaries + uses: actions/download-artifact@v4 + with: + name: phase-cli-binaries + path: dist + + - name: Build packages + run: | + chmod +x dist/phase-cli_linux_* + VERSION="${{ inputs.version }}" bash scripts/package.sh + + - name: Upload packages + uses: actions/upload-artifact@v4 + with: + name: phase-cli-packages + path: dist/pkg/ + retention-days: 7 diff --git a/.github/workflows/process-assets.yml b/.github/workflows/process-assets.yml deleted file mode 100644 index 5890fe7c..00000000 --- a/.github/workflows/process-assets.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Process and Package Assets - -on: - workflow_call: - inputs: - version: - required: true - type: string - -jobs: - process-assets: - name: Process and Package Assets - runs-on: ubuntu-24.04 - - steps: - - uses: actions/checkout@v4 - - - name: Download all artifacts - uses: actions/download-artifact@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Process assets - run: | - python process_assets.py . phase-cli-release/ --version ${{ inputs.version }} - - - name: Upload processed assets - uses: actions/upload-artifact@v4 - with: - name: phase-cli-release - path: ./phase-cli-release/ diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml deleted file mode 100644 index 1fbbbf87..00000000 --- a/.github/workflows/pypi.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Build and Deploy to PyPI - -on: - workflow_call: - inputs: - version: - required: true - type: string - secrets: - PYPI_USERNAME: - required: true - PYPI_PASSWORD: - required: true - -jobs: - build_and_deploy: - name: Build and Deploy to PyPI - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - # Versioning is handled in setup.py - - name: Build distribution - run: python setup.py sdist bdist_wheel - - - name: Upload to PyPI - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: twine upload dist/* - - - name: Upload distribution artifacts - uses: actions/upload-artifact@v4 - with: - name: dist - path: dist/ diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml deleted file mode 100644 index bd8cb0c7..00000000 --- a/.github/workflows/pytest.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Run Pytest - -on: - workflow_call: - inputs: - python_version: - required: true - type: string - -jobs: - pytest: - name: Run Tests - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python_version }} - - run: pip install -r dev-requirements.txt - - name: Set PYTHONPATH - run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV - - run: pytest tests/*.py diff --git a/.github/workflows/test-cli-install-post-release.yml b/.github/workflows/test-cli-install-post-release.yml deleted file mode 100644 index 69897e22..00000000 --- a/.github/workflows/test-cli-install-post-release.yml +++ /dev/null @@ -1,230 +0,0 @@ -name: Test CLI Installation - -on: - workflow_call: - inputs: - version: - required: true - type: string - python_version: - required: true - type: string - alpine_version: - required: true - type: string - -jobs: - test_install_x86_64: - name: Test install on Linux distros (x86_64) - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - include: - - { image: ubuntu:20.04, name: ubuntu-20.04 } - - { image: ubuntu:22.04, name: ubuntu-22.04 } - - { image: ubuntu:24.04, name: ubuntu-24.04 } - - { image: debian:bullseye, name: debian-bullseye } - - { image: debian:bookworm, name: debian-bookworm } - - { image: debian:trixie, name: debian-trixie } - - { image: fedora:39, name: fedora-39 } - - { image: fedora:40, name: fedora-40 } - - { image: fedora:41, name: fedora-41 } - - { image: fedora:42, name: fedora-42 } - - { image: rockylinux:8, name: rocky-8 } - - { image: rockylinux:9, name: rocky-9 } - - { image: amazonlinux:2023, name: amazonlinux-2023 } - - { image: alpine:3.20, name: alpine-3.20 } - - { image: alpine:3.21, name: alpine-3.21 } - - { image: alpine:3.22, name: alpine-3.22 } - - { image: archlinux:latest, name: archlinux-latest } - steps: - - uses: actions/checkout@v4 - - name: Run tests in ${{ matrix.name }} container - run: | - set -e - echo "=== Running in ${{ matrix.image }} ===" - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - ${{ matrix.image }} \ - /bin/sh -ec "\ - echo '=== ldd version ==='; \ - if command -v ldd >/dev/null 2>&1; then ldd --version || true; else echo 'ldd not found'; fi; \ - if ! command -v find >/dev/null 2>&1; then \ - (command -v dnf >/dev/null 2>&1 && dnf install -y findutils) || \ - (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install -y findutils) || \ - (command -v apk >/dev/null 2>&1 && apk add --no-cache findutils) || \ - (command -v yum >/dev/null 2>&1 && yum install -y findutils) || \ - (command -v pacman >/dev/null 2>&1 && pacman -Sy --noconfirm findutils) || true; \ - fi; \ - echo '=== Installing via install.sh ==='; \ - chmod +x ./install.sh; \ - ./install.sh --version '${{ inputs.version }}'; \ - # Ensure minimal locale/terminal env for prompt_toolkit/questionary - export TERM=xterm; \ - export LANG=C.UTF-8; \ - export LC_ALL=C.UTF-8; \ - export PYTHONIOENCODING=UTF-8; \ - echo '=== Verifying ==='; \ - if command -v phase >/dev/null 2>&1; then echo 'phase found'; else echo 'phase not found in PATH'; echo "PATH=$PATH"; ls -l /usr/local/bin || true; ls -l /usr/bin || true; exit 1; fi; \ - echo '=== phase (no args) ==='; \ - phase || true; \ - echo '=== phase -v ==='; \ - phase -v; \ - echo '=== phase users keyring ==='; \ - phase users keyring || true; \ - echo '=== phase --help (head) ==='; \ - phase --help | head -20 || true; \ - echo '=== ldd version (post) ==='; \ - ldd --version || true; \ - " - shell: bash - - test-alpine-apk-install: - name: Test Alpine APK Installation - strategy: - matrix: - include: - - { os: ubuntu-22.04, arch: x86_64 } - - { os: ubuntu-22.04-arm, arch: aarch64 } - runs-on: ${{ matrix.os }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Test in Alpine container - run: | - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - alpine:${{ inputs.alpine_version }} \ - /bin/sh -ec " - echo '=== Testing Alpine APK installation on ${{ matrix.arch }} ===' - apk add --no-cache bash - chmod +x ./install.sh - ./install.sh --version '${{ inputs.version }}' - - echo '=== Verifying installation ===' - which phase - - echo '=== Checking version ===' - installed_version=\$(phase -v) - expected_version='${{ inputs.version }}' - if [ \"\$installed_version\" != \"\$expected_version\" ]; then - echo \"Version mismatch: Expected \$expected_version, got \$installed_version\" - exit 1 - fi - echo \"CLI version matches: \$installed_version\" - - echo '=== Testing basic functionality ===' - phase --help | head -10 - " - - test_install_arm64: - name: Test install on Linux distros (ARM64) - runs-on: ubuntu-22.04-arm - strategy: - fail-fast: false - matrix: - include: - - { image: ubuntu:20.04, name: ubuntu-20.04 } - - { image: ubuntu:22.04, name: ubuntu-22.04 } - - { image: ubuntu:24.04, name: ubuntu-24.04 } - - { image: debian:bullseye, name: debian-bullseye } - - { image: debian:bookworm, name: debian-bookworm } - - { image: debian:trixie, name: debian-trixie } - - { image: fedora:39, name: fedora-39 } - - { image: fedora:40, name: fedora-40 } - - { image: fedora:41, name: fedora-41 } - - { image: fedora:42, name: fedora-42 } - - { image: amazonlinux:2023, name: amazonlinux-2023 } - - { image: alpine:3.20, name: alpine-3.20 } - - { image: alpine:3.21, name: alpine-3.21 } - - { image: alpine:3.22, name: alpine-3.22 } - steps: - - uses: actions/checkout@v4 - - name: Run tests in ${{ matrix.name }} container - run: | - set -e - echo "=== Running in ${{ matrix.image }} (arm64) ===" - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - ${{ matrix.image }} \ - /bin/sh -ec "\ - echo '=== ldd version ==='; \ - if command -v ldd >/dev/null 2>&1; then ldd --version || true; else echo 'ldd not found'; fi; \ - if ! command -v find >/dev/null 2>&1; then \ - (command -v dnf >/dev/null 2>&1 && dnf install -y findutils) || \ - (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install -y findutils) || \ - (command -v apk >/dev/null 2>&1 && apk add --no-cache findutils) || \ - (command -v yum >/dev/null 2>&1 && yum install -y findutils) || \ - (command -v pacman >/dev/null 2>&1 && pacman -Sy --noconfirm findutils) || true; \ - fi; \ - echo '=== Installing via install.sh ==='; \ - chmod +x ./install.sh; \ - ./install.sh --version '${{ inputs.version }}'; \ - # Ensure minimal locale/terminal env for prompt_toolkit/questionary - export TERM=xterm; \ - export LANG=C.UTF-8; \ - export LC_ALL=C.UTF-8; \ - export PYTHONIOENCODING=UTF-8; \ - echo '=== Verifying ==='; \ - if command -v phase >/dev/null 2>&1; then echo 'phase found'; else echo 'phase not found in PATH'; echo "PATH=$PATH"; ls -l /usr/local/bin || true; ls -l /usr/bin || true; exit 1; fi; \ - echo '=== phase (no args) ==='; \ - phase || true; \ - echo '=== phase -v ==='; \ - phase -v; \ - echo '=== phase users keyring ==='; \ - phase users keyring || true; \ - echo '=== phase --help (head) ==='; \ - phase --help | head -20 || true; \ - echo '=== ldd version (post) ==='; \ - ldd --version || true; \ - " - shell: bash - - test-pip-install: - name: Test PyPI (pip) Installation - strategy: - matrix: - os: [ubuntu-22.04, windows-2022, macos-13, macos-14, macos-15, macos-26, ubuntu-22.04-arm] - runs-on: ${{ matrix.os }} - steps: - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python_version }} - - - name: Install package from PyPI - run: | - python -m pip install --upgrade pip - pip install phase-cli==${{ inputs.version }} - - - name: Test installed package (Linux/macOS) - if: runner.os != 'Windows' - run: | - installed_version=$(phase -v) - echo "Installed version: $installed_version" - echo "Expected version: ${{ inputs.version }}" - if [ "$installed_version" != "${{ inputs.version }}" ]; then - echo "Version mismatch!" - exit 1 - fi - phase --help - - - name: Test installed package (Windows) - if: runner.os == 'Windows' - shell: pwsh - env: - PYTHONIOENCODING: UTF-8 - run: | - $installed_version = phase -v - echo "Installed version: $installed_version" - echo "Expected version: ${{ inputs.version }}" - if ($installed_version -ne "${{ inputs.version }}") { - echo "Version mismatch!" - exit 1 - } - phase --help diff --git a/.github/workflows/test-install-post-build.yml b/.github/workflows/test-install-post-build.yml index c851897c..13518fbe 100644 --- a/.github/workflows/test-install-post-build.yml +++ b/.github/workflows/test-install-post-build.yml @@ -1,4 +1,4 @@ -name: Test CLI Installation on Linux +name: "Test Installation on Linux" on: workflow_call: @@ -9,227 +9,153 @@ on: jobs: test_install_x86_64: - name: Test install on Linux distros (x86_64) + name: Test on Linux distros (x86_64) continue-on-error: true - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - - { image: ubuntu:20.04, family: deb, name: ubuntu-20.04 } - - { image: ubuntu:22.04, family: deb, name: ubuntu-22.04 } - - { image: ubuntu:24.04, family: deb, name: ubuntu-24.04 } - - { image: debian:bullseye, family: deb, name: debian-bullseye } - - { image: debian:bookworm, family: deb, name: debian-bookworm } - - { image: debian:trixie, family: deb, name: debian-trixie } - - { image: fedora:39, family: rpm, name: fedora-39 } - - { image: fedora:40, family: rpm, name: fedora-40 } - - { image: fedora:41, family: rpm, name: fedora-41 } - - { image: fedora:42, family: rpm, name: fedora-42 } - - { image: rockylinux:8, family: rpm, name: rocky-8 } - - { image: rockylinux:9, family: rpm, name: rocky-9 } - - { image: amazonlinux:2023, family: rpm, name: amazonlinux-2023 } - - { image: alpine:3.20, family: alpine, name: alpine-3.20 } - - { image: alpine:3.21, family: alpine, name: alpine-3.21 } - - { image: alpine:3.22, family: alpine, name: alpine-3.22 } - - { image: archlinux:latest, family: other, name: archlinux-latest } + # DEB package installs + - { image: "ubuntu:22.04", name: ubuntu-22.04, install: deb } + - { image: "ubuntu:24.04", name: ubuntu-24.04, install: deb } + - { image: "debian:bookworm", name: debian-bookworm, install: deb } + - { image: "debian:trixie", name: debian-trixie, install: deb } + # RPM package installs + - { image: "fedora:41", name: fedora-41, install: rpm } + - { image: "fedora:42", name: fedora-42, install: rpm } + - { image: "rockylinux:9", name: rocky-9, install: rpm } + - { image: "amazonlinux:2023", name: amazonlinux-2023, install: rpm } + # APK package installs + - { image: "alpine:3.21", name: alpine-3.21, install: apk } + - { image: "alpine:3.22", name: alpine-3.22, install: apk } + # Raw binary install + - { image: "archlinux:latest", name: archlinux-latest, install: binary } steps: - - uses: actions/checkout@v4 - - name: Download DEB artifact + - name: Download binaries uses: actions/download-artifact@v4 with: - name: phase-deb - path: deb - - name: Download RPM artifact - uses: actions/download-artifact@v4 - with: - name: phase-rpm - path: rpm - - name: Download APK artifact (amd64) - uses: actions/download-artifact@v4 - with: - name: phase_cli_linux_amd64_alpine_${{ inputs.version }} - path: apk-amd64 - continue-on-error: true - - name: Download Linux x86_64 binary artifact + name: phase-cli-binaries + path: binaries + + - name: Download packages uses: actions/download-artifact@v4 with: - name: Linux-binary - path: linux-amd64 - continue-on-error: true - - name: Run tests in ${{ matrix.name }} container + name: phase-cli-packages + path: packages + + - name: Run tests in ${{ matrix.name }} run: | set -e - echo "=== Running in ${{ matrix.image }} ===" + chmod +x binaries/phase-cli_linux_amd64 docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ + -v "${PWD}/binaries":/binaries \ + -v "${PWD}/packages":/packages \ ${{ matrix.image }} \ - /bin/sh -ec "\ - echo '=== ldd version ==='; \ - if command -v ldd >/dev/null 2>&1; then ldd --version || true; else echo 'ldd not found'; fi; \ - echo '=== Installing package ==='; \ - case '${{ matrix.family }}' in \ - deb) \ - apt-get update; \ - apt-get install -y ca-certificates; \ - dpkg -i deb/*.deb || apt-get -f install -y; \ - ;; \ - rpm) \ - # Prefer installing local RPM without contacting repos (avoids mirror DNS failures) - if command -v rpm >/dev/null 2>&1 && rpm -Uvh --nodeps --nosignature ./rpm/*.rpm; then \ - :; \ - elif command -v dnf >/dev/null 2>&1; then \ - dnf install -y --disablerepo='*' ./rpm/*.rpm || rpm -Uvh --nodeps --nosignature ./rpm/*.rpm; \ - elif command -v microdnf >/dev/null 2>&1; then \ - microdnf --disablerepo='*' install -y ./rpm/*.rpm || rpm -Uvh --nodeps --nosignature ./rpm/*.rpm; \ - else \ - yum --disablerepo='*' install -y ./rpm/*.rpm || rpm -Uvh --nodeps --nosignature ./rpm/*.rpm; \ - fi; \ - ;; \ - alpine) \ - apk update; \ - apk add --no-cache libstdc++ findutils; \ - ALP_VER=\$(cut -d. -f1,2 /etc/alpine-release); \ - if [ "\$ALP_VER" = "3.18" ] || [ "\$ALP_VER" = "3.19" ]; then \ - echo 'Using binary artifact for Alpine < 3.20'; \ - BINDIR=\$(find ./linux-amd64 -maxdepth 2 -type f -name phase -printf '%h\n' -quit); \ - if [ -z "\$BINDIR" ]; then echo 'x86_64 binary artifact not found'; exit 1; fi; \ - install -Dm755 "\$BINDIR/phase" /usr/local/bin/phase; \ - if [ -d "\$BINDIR/_internal" ]; then \ - rm -rf /usr/local/bin/_internal; \ - cp -a "\$BINDIR/_internal" /usr/local/bin/_internal; \ - fi; \ - else \ - apk add --no-cache --allow-untrusted ./apk-amd64/*.apk; \ - fi; \ - ;; \ - other) \ - echo 'Installing from local x86_64 binary artifact'; \ - if ! command -v find >/dev/null 2>&1; then \ - (command -v dnf >/dev/null 2>&1 && dnf install -y findutils) || \ - (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install -y findutils) || \ - (command -v apk >/dev/null 2>&1 && apk add --no-cache findutils) || \ - (command -v yum >/dev/null 2>&1 && yum install -y findutils) || \ - (command -v pacman >/dev/null 2>&1 && pacman -Sy --noconfirm findutils) || true; \ - fi; \ - BINDIR=\$(find ./linux-amd64 -maxdepth 2 -type f -name phase -printf '%h\n' -quit); \ - if [ -z "\$BINDIR" ]; then echo 'x86_64 binary artifact not found'; exit 1; fi; \ - install -Dm755 "\$BINDIR/phase" /usr/local/bin/phase; \ - if [ -d "\$BINDIR/_internal" ]; then \ - rm -rf /usr/local/bin/_internal; \ - cp -a "\$BINDIR/_internal" /usr/local/bin/_internal; \ - fi; \ - ;; \ - esac; \ - # Ensure minimal locale/terminal env for prompt_toolkit/questionary - export TERM=xterm; \ - export LANG=C.UTF-8; \ - export LC_ALL=C.UTF-8; \ - export PYTHONIOENCODING=UTF-8; \ - echo '=== Verifying ==='; \ - if command -v phase >/dev/null 2>&1; then echo 'phase found'; else echo 'phase not found in PATH'; echo "PATH=$PATH"; ls -l /usr/local/bin || true; ls -l /usr/bin || true; exit 1; fi; \ - echo '=== phase (no args) ==='; \ - phase || true; \ - echo '=== phase -v ==='; \ - phase -v; \ - echo '=== phase users keyring ==='; \ - phase users keyring || true; \ - echo '=== phase --help (head) ==='; \ - phase --help | head -20 || true; \ - echo '=== ldd version (post) ==='; \ - ldd --version || true; \ + /bin/sh -c " + echo '=== Installing (${{ matrix.install }}) ===' + case '${{ matrix.install }}' in + deb) + dpkg -i /packages/phase_${{ inputs.version }}_amd64.deb + ;; + rpm) + rpm -i /packages/phase-${{ inputs.version }}-1.x86_64.rpm + ;; + apk) + apk add --allow-untrusted /packages/phase_${{ inputs.version }}_x86_64.apk + ;; + binary) + install -Dm755 /binaries/phase-cli_linux_amd64 /usr/local/bin/phase + ;; + esac + + echo '=== Verify phase exists ===' + command -v phase + + echo '=== phase --version ===' + phase --version + + echo '=== phase --help (head) ===' + phase --help | head -20 + + echo '=== version check ===' + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\" + exit 1 + fi + + echo 'All checks passed' " shell: bash test_install_arm64: - name: Test install on Linux distros (ARM64) + name: Test on Linux distros (ARM64) continue-on-error: true runs-on: ubuntu-22.04-arm strategy: fail-fast: false matrix: include: - - { image: ubuntu:20.04, family: other, name: ubuntu-20.04 } - - { image: ubuntu:22.04, family: other, name: ubuntu-22.04 } - - { image: ubuntu:24.04, family: other, name: ubuntu-24.04 } - - { image: debian:bullseye, family: other, name: debian-bullseye } - - { image: debian:bookworm, family: other, name: debian-bookworm } - - { image: debian:trixie, family: other, name: debian-trixie } - - { image: fedora:39, family: other, name: fedora-39 } - - { image: fedora:40, family: other, name: fedora-40 } - - { image: fedora:41, family: other, name: fedora-41 } - - { image: fedora:42, family: other, name: fedora-42 } - - { image: amazonlinux:2023, family: other, name: amazonlinux-2023 } - - { image: alpine:3.20, family: alpine, name: alpine-3.20 } - - { image: alpine:3.21, family: alpine, name: alpine-3.21 } - - { image: alpine:3.22, family: alpine, name: alpine-3.22 } + - { image: "ubuntu:24.04", name: ubuntu-24.04, install: deb } + - { image: "debian:bookworm", name: debian-bookworm, install: deb } + - { image: "fedora:42", name: fedora-42, install: rpm } + - { image: "alpine:3.21", name: alpine-3.21, install: apk } + - { image: "amazonlinux:2023", name: amazonlinux-2023, install: rpm } steps: - - uses: actions/checkout@v4 - - name: Download APK artifact (arm64) + - name: Download binaries uses: actions/download-artifact@v4 with: - name: phase_cli_linux_arm64_alpine_${{ inputs.version }} - path: apk-arm64 - continue-on-error: true - - name: Download Linux ARM64 binary artifact + name: phase-cli-binaries + path: binaries + + - name: Download packages uses: actions/download-artifact@v4 with: - name: Linux-binary-arm64 - path: linux-arm64 - continue-on-error: true - - name: Run tests in ${{ matrix.name }} container + name: phase-cli-packages + path: packages + + - name: Run tests in ${{ matrix.name }} (arm64) run: | set -e - echo "=== Running in ${{ matrix.image }} (arm64) ===" + chmod +x binaries/phase-cli_linux_arm64 docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ + -v "${PWD}/binaries":/binaries \ + -v "${PWD}/packages":/packages \ ${{ matrix.image }} \ - /bin/sh -ec "\ - echo '=== ldd version ==='; \ - if command -v ldd >/dev/null 2>&1; then ldd --version || true; else echo 'ldd not found'; fi; \ - case '${{ matrix.family }}' in \ - alpine) \ - apk update; \ - apk add --no-cache libstdc++; \ - apk add --no-cache --allow-untrusted ./apk-arm64/*.apk; \ - ;; \ - other) \ - echo 'Installing from local ARM64 binary artifact'; \ - if ! command -v find >/dev/null 2>&1; then \ - (command -v dnf >/dev/null 2>&1 && dnf install -y findutils) || \ - (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install -y findutils) || \ - (command -v apk >/dev/null 2>&1 && apk add --no-cache findutils) || \ - (command -v yum >/dev/null 2>&1 && yum install -y findutils) || \ - (command -v pacman >/dev/null 2>&1 && pacman -Sy --noconfirm findutils) || true; \ - fi; \ - BINDIR=\$(find ./linux-arm64 -maxdepth 2 -type f -name phase -printf '%h\n' -quit); \ - if [ -z "\$BINDIR" ]; then echo 'ARM64 binary artifact not found'; exit 1; fi; \ - install -Dm755 "\$BINDIR/phase" /usr/local/bin/phase; \ - if [ -d "\$BINDIR/_internal" ]; then \ - rm -rf /usr/local/bin/_internal; \ - cp -a "\$BINDIR/_internal" /usr/local/bin/_internal; \ - fi; \ - ;; \ - esac; \ - # Ensure minimal locale/terminal env for prompt_toolkit/questionary - export TERM=xterm; \ - export LANG=C.UTF-8; \ - export LC_ALL=C.UTF-8; \ - export PYTHONIOENCODING=UTF-8; \ - echo '=== Verifying ==='; \ - if command -v phase >/dev/null 2>&1; then echo 'phase found'; else echo 'phase not found in PATH'; echo "PATH=$PATH"; ls -l /usr/local/bin || true; ls -l /usr/bin || true; exit 1; fi; \ - echo '=== phase (no args) ==='; \ - phase || true; \ - echo '=== phase -v ==='; \ - phase -v; \ - echo '=== phase users keyring ==='; \ - phase users keyring || true; \ - echo '=== phase --help (head) ==='; \ - phase --help | head -20 || true; \ - echo '=== ldd version (post) ==='; \ - ldd --version || true; \ + /bin/sh -c " + echo '=== Installing (${{ matrix.install }}) ===' + case '${{ matrix.install }}' in + deb) + dpkg -i /packages/phase_${{ inputs.version }}_arm64.deb + ;; + rpm) + rpm -i /packages/phase-${{ inputs.version }}-1.aarch64.rpm + ;; + apk) + apk add --allow-untrusted /packages/phase_${{ inputs.version }}_aarch64.apk + ;; + binary) + install -Dm755 /binaries/phase-cli_linux_arm64 /usr/local/bin/phase + ;; + esac + + echo '=== Verify phase exists ===' + command -v phase + + echo '=== phase --version ===' + phase --version + + echo '=== phase --help (head) ===' + phase --help | head -20 + + echo '=== version check ===' + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\" + exit 1 + fi + + echo 'All checks passed' " shell: bash - diff --git a/.github/workflows/test-install-script.yml b/.github/workflows/test-install-script.yml new file mode 100644 index 00000000..8a9c2bd9 --- /dev/null +++ b/.github/workflows/test-install-script.yml @@ -0,0 +1,131 @@ +name: "Test install.sh (post-release)" + +on: + workflow_call: + inputs: + version: + required: true + type: string + +jobs: + test_install_script_x86_64: + name: Test install.sh (x86_64) + continue-on-error: true + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { image: "ubuntu:22.04", name: ubuntu-22.04 } + - { image: "ubuntu:24.04", name: ubuntu-24.04 } + - { image: "debian:bookworm", name: debian-bookworm } + - { image: "fedora:42", name: fedora-42 } + - { image: "rockylinux:9", name: rocky-9 } + - { image: "amazonlinux:2023", name: amazonlinux-2023 } + - { image: "alpine:3.21", name: alpine-3.21 } + - { image: "alpine:3.22", name: alpine-3.22 } + - { image: "archlinux:latest", name: archlinux-latest } + steps: + - uses: actions/checkout@v4 + + - name: Test install.sh in ${{ matrix.name }} + run: | + set -e + docker run --rm \ + -v "${PWD}/install.sh":/install.sh:ro \ + ${{ matrix.image }} \ + /bin/sh -c " + # Install curl or wget (needed by install.sh) + if command -v apt-get > /dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1 + elif command -v dnf > /dev/null 2>&1; then + dnf install -y curl ca-certificates > /dev/null 2>&1 + elif command -v yum > /dev/null 2>&1; then + yum install -y curl ca-certificates > /dev/null 2>&1 + elif command -v apk > /dev/null 2>&1; then + apk add --no-cache curl ca-certificates > /dev/null 2>&1 + elif command -v pacman > /dev/null 2>&1; then + pacman -Sy --noconfirm curl ca-certificates > /dev/null 2>&1 + fi + + echo '=== Running install.sh --version ${{ inputs.version }} ===' + sh /install.sh --version '${{ inputs.version }}' + + echo '=== Verify phase exists ===' + command -v phase + + echo '=== phase --version ===' + phase --version + + echo '=== phase --help (head) ===' + phase --help | head -20 + + echo '=== version check ===' + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\" + exit 1 + fi + + echo 'All checks passed' + " + shell: bash + + test_install_script_arm64: + name: Test install.sh (ARM64) + continue-on-error: true + runs-on: ubuntu-22.04-arm + strategy: + fail-fast: false + matrix: + include: + - { image: "ubuntu:24.04", name: ubuntu-24.04 } + - { image: "debian:bookworm", name: debian-bookworm } + - { image: "fedora:42", name: fedora-42 } + - { image: "alpine:3.21", name: alpine-3.21 } + - { image: "amazonlinux:2023", name: amazonlinux-2023 } + steps: + - uses: actions/checkout@v4 + + - name: Test install.sh in ${{ matrix.name }} (arm64) + run: | + set -e + docker run --rm \ + -v "${PWD}/install.sh":/install.sh:ro \ + ${{ matrix.image }} \ + /bin/sh -c " + # Install curl or wget (needed by install.sh) + if command -v apt-get > /dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1 + elif command -v dnf > /dev/null 2>&1; then + dnf install -y curl ca-certificates > /dev/null 2>&1 + elif command -v yum > /dev/null 2>&1; then + yum install -y curl ca-certificates > /dev/null 2>&1 + elif command -v apk > /dev/null 2>&1; then + apk add --no-cache curl ca-certificates > /dev/null 2>&1 + elif command -v pacman > /dev/null 2>&1; then + pacman -Sy --noconfirm curl ca-certificates > /dev/null 2>&1 + fi + + echo '=== Running install.sh --version ${{ inputs.version }} ===' + sh /install.sh --version '${{ inputs.version }}' + + echo '=== Verify phase exists ===' + command -v phase + + echo '=== phase --version ===' + phase --version + + echo '=== phase --help (head) ===' + phase --help | head -20 + + echo '=== version check ===' + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\" + exit 1 + fi + + echo 'All checks passed' + " + shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..92637928 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: "Test and Vet" + +on: + workflow_call: + +jobs: + test: + name: Go Test & Vet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache-dependency-path: src/go.sum + + - name: Clone Go SDK + uses: actions/checkout@v4 + with: + repository: phasehq/golang-sdk + path: golang-sdk + + - name: Patch go.mod replace directive for CI + working-directory: src + run: | + go mod edit -replace github.com/phasehq/golang-sdk=../golang-sdk + + - name: Download dependencies + working-directory: src + run: go mod download + + - name: Vet + working-directory: src + run: go vet ./... + + - name: Build (smoke test) + working-directory: src + run: CGO_ENABLED=0 go build -o /dev/null ./ diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 2c5253de..b971b75a 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -1,41 +1,28 @@ -name: Validate and set version +name: "Validate and set version" on: workflow_call: outputs: version: - description: "Validated Phase CLI version from const.py and APKBUILD" - value: ${{ jobs.validate_version.outputs.version }} + description: "Phase CLI version extracted from Go source" + value: ${{ jobs.extract_version.outputs.version }} jobs: - validate_version: - name: Validate and Extract Version + extract_version: + name: Extract Go CLI Version runs-on: ubuntu-latest outputs: - version: ${{ steps.extract_version.outputs.version }} + version: ${{ steps.get_version.outputs.version }} steps: - uses: actions/checkout@v4 - - name: Extract version from const.py - id: extract_version + - name: Extract version from src/pkg/version/version.go + id: get_version run: | - PHASE_CLI_VERSION=$(grep -oP '(?<=__version__ = ")[^"]*' phase_cli/utils/const.py) - echo "version=$PHASE_CLI_VERSION" >> $GITHUB_OUTPUT - # echo "$PHASE_CLI_VERSION" > ./PHASE_CLI_VERSION.txt - - - name: Extract version from APKBUILD - id: extract_apkbuild_version - run: | - APKBUILD_VERSION=$(grep -oP '(?<=pkgver=)[^\s]*' APKBUILD) - echo "apkbuild_version=$APKBUILD_VERSION" >> $GITHUB_OUTPUT - - - name: Compare versions - run: | - if [ "${{ steps.extract_version.outputs.version }}" != "${{ steps.extract_apkbuild_version.outputs.apkbuild_version }}" ]; then - echo "Version mismatch detected!" - echo "const.py version: ${{ steps.extract_version.outputs.version }}" - echo "APKBUILD version: ${{ steps.extract_apkbuild_version.outputs.apkbuild_version }}" + VERSION=$(grep -oP '(?<=var Version = ")[^"]*' src/pkg/version/version.go) + if [ -z "$VERSION" ]; then + echo "Error: Could not extract version from src/pkg/version/version.go" exit 1 - else - echo "Versions match: ${{ steps.extract_version.outputs.version }}" fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" diff --git a/Dockerfile b/Dockerfile index 25fc9931..0c2d2327 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,36 @@ -FROM python:3.12-alpine3.19 +# Build stage: compile Go binary +FROM golang:1.24-alpine AS builder -# Set source directory -WORKDIR /app +ARG VERSION +ARG TARGETOS=linux +ARG TARGETARCH + +WORKDIR /build + +# Copy Go SDK (placed alongside by CI or Docker build context) +COPY golang-sdk/ ./golang-sdk/ # Copy source -COPY phase_cli ./phase_cli -COPY setup.py requirements.txt LICENSE README.md ./ +COPY src/ ./src/ -# Install build dependencies and the CLI -RUN apk add --no-cache --virtual .build-deps gcc musl-dev libffi-dev openssl-dev && \ - pip install --no-cache-dir . && \ - apk del .build-deps +WORKDIR /build/src -# CLI Entrypoint -ENTRYPOINT ["phase"] +# Patch replace directive for build context +RUN go mod edit -replace github.com/phasehq/golang-sdk=../golang-sdk + +# Download dependencies and build +RUN go mod download && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags "-s -w${VERSION:+ -X github.com/phasehq/cli/pkg/version.Version=${VERSION}}" \ + -o /phase ./ -# Run help by default -CMD ["--help"] \ No newline at end of file +# Runtime stage: minimal scratch image +FROM alpine:3.21 + +# Install CA certificates for HTTPS API calls +RUN apk add --no-cache ca-certificates + +COPY --from=builder /phase /usr/local/bin/phase + +ENTRYPOINT ["phase"] +CMD ["--help"] diff --git a/README.md b/README.md index fceb67c1..170b5c53 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,66 @@ # phase cli -```fish +``` Ī» phase --help -Securely manage application secrets and environment variables with Phase. - - /$$ - | $$ - /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ - /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$ -| $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$ -| $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/ -| $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$ -| $$____/ |__/ |__/ \_______/|_______/ \_______/ -| $$ -|__/ - -options: - -h, --help show this help message and exit - --version, -v - show program's version number and exit -Commands: +Keep Secrets. + + /$$ + | $$ + /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ + /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$ + | $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$ + | $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/ + | $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$ + | $$____/ |__/ |__/ \_______/|_______/ \_______/ + | $$ + |__/ - auth šŸ’» Authenticate with Phase - init šŸ”— Link your project with your Phase app - run šŸš€ Run and inject secrets to your app - shell 🐚 Launch a sub-shell with secrets as environment variables (BETA) - secrets šŸ—ļø Manage your secrets - secrets list šŸ“‡ List all the secrets - secrets get šŸ” Get a specific secret by key - secrets create šŸ’³ Create a new secret - secrets update šŸ“ Update an existing secret - secrets delete šŸ—‘ļø Delete a secret - secrets import šŸ“© Import secrets from a .env file - secrets export 🄔 Export secrets in a dotenv format - dynamic-secrets āš”ļø Manage dynamic secrets - dynamic-secrets list šŸ“‡ List dynamic secrets & metadata - dynamic-secrets lease šŸ“œ Manage dynamic secret leases - dynamic-secrets lease get šŸ” Get leases for a dynamic secret - dynamic-secrets lease renew šŸ” Renew a lease - dynamic-secrets lease revoke šŸ—‘ļø Revoke a lease - dynamic-secrets lease generate ✨ Generate a lease (create fresh dynamic secrets) - users šŸ‘„ Manage users and accounts - users whoami šŸ™‹ See details of the current user - users switch šŸŖ„ Switch between Phase users, orgs and hosts - users logout šŸƒ Logout from phase-cli - users keyring šŸ” Display information about the Phase keyring - docs šŸ“– Open the Phase CLI Docs in your browser - console šŸ–„ļø Open the Phase Console in your browser - update šŸ†™ Update the Phase CLI to the latest version +Commands: + auth šŸ’» Authenticate with Phase + init šŸ”— Link your project with your Phase app + run šŸš€ Run and inject secrets to your app + shell 🐚 Launch a sub-shell with secrets as environment variables + secrets list šŸ“‡ List all the secrets + secrets get šŸ” Fetch details about a secret in JSON + secrets create šŸ’³ Create a new secret + secrets update šŸ“ Update an existing secret + secrets delete šŸ—‘ļø Delete a secret + secrets import šŸ“© Import secrets from a .env file + secrets export 🄔 Export secrets in a specific format + dynamic-secrets list šŸ“‡ List dynamic secrets & metadata + dynamic-secrets lease generate ✨ Generate a lease (create fresh dynamic secret) + dynamic-secrets lease get šŸ” Get leases for a dynamic secret + dynamic-secrets lease renew šŸ” Renew a lease + dynamic-secrets lease revoke šŸ—‘ļø Revoke a lease + users whoami šŸ™‹ See details of the current user + users switch šŸŖ„ Switch between Phase users, orgs and hosts + users logout šŸƒ Logout from phase-cli + users keyring šŸ” Display information about the Phase keyring + console šŸ–„ļø Open the Phase Console in your browser + docs šŸ“– Open the Phase CLI Docs in your browser + completion āŒØļø Generate the autocompletion script for the specified shell + +Flags: + -h, --help help for phase + -v, --version version for phase ``` ## Features -- Inject secrets to your application during runtime without any code changes -- Import your existing .env files and encrypt them -- Sync encrypted secrets with Phase cloud -- Multiple environments eg. dev, testing, staging, production - -## See it in action - -[![asciicast](media/phase-cli-demo.gif)](asciinema-cli-demo) +- **End-to-end encryption** — secrets are encrypted client-side before leaving your machine +- **`phase run`** — inject secrets as environment variables into any command without code changes +- **`phase shell`** — launch a sub-shell (bash, zsh, fish, etc.) with secrets preloaded +- **Dynamic secrets** — generate short-lived credentials (e.g. database passwords) with automatic lease management (generate, renew, revoke) +- **Secret references** — reference secrets across environments and apps, resolved automatically at runtime +- **Personal overrides** — override shared secrets locally without affecting your team +- **Import / Export** — import from `.env` files; export to dotenv, JSON, YAML, TOML, CSV, XML, HCL, INI, Java properties, and more +- **Path-based organisation** — organise secrets in hierarchical paths for monorepos and microservices +- **Tagging** — tag secrets and filter operations by tag +- **Random secret generation** — generate hex, alphanumeric, 128-bit, or 256-bit keys on create or update +- **Multiple auth methods** — web-based login, personal access tokens, service account tokens, and AWS IAM identity auth +- **Multi-user & multi-org** — switch between Phase accounts, orgs, and self-hosted instances +- **OS keyring integration** — credentials stored in macOS Keychain, GNOME Keyring, or Windows Credential Manager +- **Multiple environments** — dev, staging, production, and custom environments with per-project defaults via `phase init` ## Installation @@ -71,9 +72,11 @@ curl -fsSL https://pkg.phase.dev/install.sh | bash ## Usage -### Login +### Prerequisites -Create an app in the [Phase Console](https://console.phase.dev) and copy appID and pss +- Create an app in the [Phase Console](https://console.phase.dev) + +### Login ```bash phase auth @@ -87,7 +90,7 @@ Link the phase cli to your project phase init ``` -### Import .env +### Import .env (optional) Import and encrypt existing secrets and environment variables @@ -95,7 +98,7 @@ Import and encrypt existing secrets and environment variables phase secrets import .env ``` -## List / view secrets +### List / view secrets ```bash phase secrets list --show @@ -119,32 +122,64 @@ phase run go run phase run npm start ``` -## Development: +## Development -### Create a virtualenv: +### Prerequisites + +- [Go](https://go.dev/dl/) 1.24 or later + +### Project structure + +``` +src/ +ā”œā”€ā”€ main.go # Entrypoint +ā”œā”€ā”€ cmd/ # Cobra command definitions +ā”œā”€ā”€ pkg/ +│ ā”œā”€ā”€ config/ # Config file handling (~/.phase/, .phase.json) +│ ā”œā”€ā”€ display/ # Output formatting (tree view, tables) +│ ā”œā”€ā”€ errors/ # Error types +│ ā”œā”€ā”€ keyring/ # OS keyring integration +│ ā”œā”€ā”€ phase/ # Phase client helpers (auth, init) +│ ā”œā”€ā”€ util/ # Misc utilities (color, spinner, browser) +│ └── version/ # Version constant +└── go.mod +``` + +### Run from source ```bash -python -m venv venv +cd src +go run main.go --help ``` -### Switch to the virtualenv: +### Build a binary ```bash -source venv/bin/activate +cd src +go build -o phase . +./phase --version ``` -### Install dependencies: +You can set the version at build time with `-ldflags`: ```bash -pip install -r requirements.txt +go build -ldflags "-X github.com/phasehq/cli/pkg/version.Version=2.0.0" -o phase . ``` -### Install the CLI in editable mode: +### Run tests ```bash -pip install -e . +cd src +go test ./... ``` +### Install locally + +Build and move the binary somewhere on your `$PATH`: + ```bash +cd src +go build -o phase . +sudo mv phase /usr/local/bin/ phase --version ``` \ No newline at end of file diff --git a/install.sh b/install.sh index 295a7ecc..6fb34b16 100755 --- a/install.sh +++ b/install.sh @@ -1,285 +1,258 @@ -#!/bin/bash +#!/bin/sh +# +# /$$ +# | $$ +# /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ +# /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$ +# | $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$ +# | $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/ +# | $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$ +# | $$____/ |__/ |__/ \_______/|_______/ \_______/ +# | $$ +# |__/ +# +# Phase CLI installer. +# +# Usage: +# curl -fsSL https://pkg.phase.dev/install.sh | sh +# curl -fsSL https://pkg.phase.dev/install.sh | sh -s -- --version 2.0.0 +# +# Supports: Linux, macOS, FreeBSD, OpenBSD, NetBSD +# Architectures: x86_64, arm64, armv7, mips, mips64, riscv64, ppc64le, s390x set -e REPO="phasehq/cli" -BASE_URL="https://github.com/$REPO/releases/download" +INSTALL_DIR="/usr/local/bin" +BINARY_NAME="phase" -detect_os() { - if [ -f /etc/os-release ]; then - . /etc/os-release - OS=$ID - else - echo "Can't detect OS type." - exit 1 - fi -} +# --- Helpers --- -has_sudo_access() { - if sudo -n true 2>/dev/null; then - return 0 - else - return 1 - fi +die() { + echo "Error: $1" >&2 + exit 1 } -can_install_without_sudo() { - case $OS in - ubuntu|debian) - if dpkg -l >/dev/null 2>&1; then - return 0 - fi - ;; - fedora|rhel|centos|amzn|rocky) - if rpm -q rpm >/dev/null 2>&1; then - return 0 - fi - ;; - alpine) - if apk --version >/dev/null 2>&1; then - return 0 - fi - ;; - arch) - if pacman -V >/dev/null 2>&1; then - return 0 - fi - ;; - esac - return 1 +info() { + echo " $1" } -prompt_sudo() { - if [ "$EUID" -ne 0 ]; then - if command -v sudo >/dev/null 2>&1; then - echo "This operation requires elevated privileges. Please enter your sudo password if prompted." - sudo -v - if [ $? -ne 0 ]; then - echo "Failed to obtain sudo privileges. Exiting." - exit 1 - fi - else - echo "Error: This script must be run as root or with sudo privileges." - exit 1 - fi +# Run a command with elevated privileges if needed +do_install() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif command -v sudo > /dev/null 2>&1; then + sudo "$@" + elif command -v doas > /dev/null 2>&1; then + doas "$@" + else + die "This script must be run as root or with sudo/doas available." fi } -install_tool() { - local TOOL=$1 - echo "Installing $TOOL..." - if [ "$EUID" -eq 0 ] || can_install_without_sudo; then - case $OS in - ubuntu|debian) - apt-get update && apt-get install -y $TOOL - ;; - fedora|rhel|centos|amzn|rocky) - yum install -y $TOOL - ;; - alpine) - apk add $TOOL - ;; - arch) - pacman -Sy --noconfirm $TOOL - ;; - esac +# Download a URL to a file. Prefers curl, falls back to wget. +fetch() { + if command -v curl > /dev/null 2>&1; then + curl -fsSL -o "$2" "$1" + elif command -v wget > /dev/null 2>&1; then + wget -qO "$2" "$1" else - prompt_sudo - case $OS in - ubuntu|debian) - sudo apt-get update && sudo apt-get install -y $TOOL - ;; - fedora|rhel|centos|amzn|rocky) - sudo yum install -y $TOOL - ;; - alpine) - sudo apk add $TOOL - ;; - arch) - sudo pacman -Sy --noconfirm $TOOL - ;; - esac + die "curl or wget is required to download files." fi } -check_required_tools() { - for TOOL in wget curl jq unzip; do - if ! command -v $TOOL > /dev/null; then - install_tool $TOOL - fi - done - # sha256sum is provided by coreutils on most distros - if ! command -v sha256sum > /dev/null; then - install_tool coreutils +# Download a URL to stdout. +fetch_stdout() { + if command -v curl > /dev/null 2>&1; then + curl -fsSL "$1" + elif command -v wget > /dev/null 2>&1; then + wget -qO - "$1" + else + die "curl or wget is required to download files." fi } +# --- Detection --- + +detect_platform() { + OS="$(uname -s)" + ARCH="$(uname -m)" + + case "$OS" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + FreeBSD) OS="freebsd" ;; + OpenBSD) OS="openbsd" ;; + NetBSD) OS="netbsd" ;; + MINGW*|MSYS*|CYGWIN*) die "Windows is not supported by this script. Use Scoop instead: scoop bucket add phasehq https://github.com/phasehq/scoop-cli.git && scoop install phase" ;; + *) die "Unsupported operating system: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + armv7l|armv6l) ARCH="arm" ;; + mips) ARCH="mips" ;; + mipsel) ARCH="mipsle" ;; + mips64) ARCH="mips64" ;; + mips64el) ARCH="mips64le" ;; + riscv64) ARCH="riscv64" ;; + ppc64le) ARCH="ppc64le" ;; + s390x) ARCH="s390x" ;; + i386|i686) ARCH="386" ;; + *) die "Unsupported architecture: $ARCH" ;; + esac +} + get_latest_version() { - curl -s "https://api.github.com/repos/$REPO/releases/latest" | jq -r .tag_name | cut -c 2- + fetch_stdout "https://api.github.com/repos/$REPO/releases/latest" | \ + sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' } -wget_download() { - if wget --help 2>&1 | grep -q '\--show-progress'; then - wget --show-progress "$1" -O "$2" - else - wget "$1" -O "$2" +# --- Install --- + +cleanup_legacy() { + # Clean up any remnants of the PyInstaller-based Python CLI (v1.x). + # The old install script installed differently depending on distro/arch: + # A) DEB package (Ubuntu/Debian amd64): dpkg package "phase" -> /usr/lib/phase/ + /usr/bin/phase symlink + # B) RPM package (Fedora/RHEL amd64): rpm package "phase" -> /usr/lib/phase/ + /usr/bin/phase symlink + # C) APK package (Alpine amd64+arm64): apk package "phase" -> Python setuptools install + /usr/bin/phase + # D) Binary zip (arm64 non-Alpine, Arch): raw files -> /usr/local/bin/phase + /usr/local/bin/_internal/ + + found_legacy=false + + # --- Package manager cleanup (removes tracked files + database entry) --- + + # Scenario A: DEB package + if command -v dpkg > /dev/null 2>&1; then + if dpkg -s phase > /dev/null 2>&1; then + info "Removing legacy Phase CLI v1 DEB package..." + do_install dpkg --purge phase 2>/dev/null || do_install dpkg -r phase 2>/dev/null || true + found_legacy=true + fi fi -} -verify_checksum() { - local file="$1" - local checksum_url="$2" - local checksum_file="$TMPDIR/checksum.sha256" - - wget_download "$checksum_url" "$checksum_file" - - while IFS= read -r line; do - local expected_checksum=$(echo "$line" | awk '{print $1}') - local target_file=$(echo "$line" | awk '{print $2}') - - if [[ -e "$target_file" ]]; then - local computed_checksum=$(sha256sum "$target_file" | awk '{print $1}') - - if [[ "$expected_checksum" != "$computed_checksum" ]]; then - echo "Checksum verification failed for $target_file!" - exit 1 - fi + # Scenario B: RPM package + if command -v rpm > /dev/null 2>&1; then + if rpm -q phase > /dev/null 2>&1; then + info "Removing legacy Phase CLI v1 RPM package..." + do_install rpm -e --nodeps phase 2>/dev/null || true + found_legacy=true fi - done < "$checksum_file" -} + fi -install_from_binary() { - ARCH=$(uname -m) - case $ARCH in - x86_64) - ZIP_URL="$BASE_URL/v$VERSION/phase_cli_linux_amd64_$VERSION.zip" - CHECKSUM_URL="$BASE_URL/v$VERSION/phase_cli_linux_amd64_$VERSION.zip.sha256" - EXTRACT_DIR="Linux-binary/phase" - ;; - aarch64) - ZIP_URL="$BASE_URL/v$VERSION/phase_cli_linux_arm64_$VERSION.zip" - CHECKSUM_URL="$BASE_URL/v$VERSION/phase_cli_linux_arm64_$VERSION.zip.sha256" - EXTRACT_DIR="Linux-binary-arm64/phase" - ;; - *) - echo "Unsupported architecture: $ARCH. This script supports x86_64 and arm64." - exit 1 - ;; - esac + # Scenario C: APK package + if command -v apk > /dev/null 2>&1; then + if apk info -e phase > /dev/null 2>&1; then + info "Removing legacy Phase CLI v1 APK package..." + do_install apk del phase 2>/dev/null || true + found_legacy=true + fi + fi - wget_download "$ZIP_URL" "$TMPDIR/phase_cli_${ARCH}_$VERSION.zip" - unzip "$TMPDIR/phase_cli_${ARCH}_$VERSION.zip" -d "$TMPDIR" + # --- Filesystem cleanup (catch anything left after package removal) --- - BINARY_PATH="$TMPDIR/$EXTRACT_DIR/phase" - INTERNAL_DIR_PATH="$TMPDIR/$EXTRACT_DIR/_internal" + # Scenario D: PyInstaller _internal directory from binary zip install + if [ -d "${INSTALL_DIR}/_internal" ]; then + info "Removing legacy PyInstaller _internal directory..." + do_install rm -rf "${INSTALL_DIR}/_internal" + found_legacy=true + fi - verify_checksum "$BINARY_PATH" "$CHECKSUM_URL" - chmod +x "$BINARY_PATH" + # Stale symlink at /usr/bin/phase from DEB/RPM packages + if [ -L "/usr/bin/phase" ]; then + link_target=$(readlink "/usr/bin/phase" 2>/dev/null || true) + case "$link_target" in + *lib/phase/phase*) + info "Removing stale symlink /usr/bin/phase..." + do_install rm -f "/usr/bin/phase" + found_legacy=true + ;; + esac + fi - if [ "$EUID" -eq 0 ]; then - # Running as root, no need for sudo - mv "$BINARY_PATH" /usr/local/bin/phase - mv "$INTERNAL_DIR_PATH" /usr/local/bin/_internal - elif command -v sudo >/dev/null 2>&1; then - # Not root, but sudo is available - echo "This operation requires elevated privileges. Please enter your sudo password if prompted." - sudo mv "$BINARY_PATH" /usr/local/bin/phase - sudo mv "$INTERNAL_DIR_PATH" /usr/local/bin/_internal - else - echo "Error: This script must be run as root or with sudo privileges." - exit 1 + # Leftover /usr/lib/phase/ directory from DEB/RPM packages + if [ -d "/usr/lib/phase" ]; then + info "Removing legacy /usr/lib/phase/ directory..." + do_install rm -rf "/usr/lib/phase" + found_legacy=true fi -} -install_package() { - ARCH=$(uname -m) - # For non-Alpine ARM64 systems, fall back to binary installation - if [ "$ARCH" = "aarch64" ] && [ "$OS" != "alpine" ]; then - install_from_binary - echo "phase-cli version $VERSION successfully installed" - return + if [ "$found_legacy" = true ]; then + info "Legacy Phase CLI v1 cleanup complete." fi +} - if [ "$EUID" -ne 0 ] && ! can_install_without_sudo; then - prompt_sudo +install_binary() { + version="$1" + + asset_name="phase-cli_${OS}_${ARCH}" + download_url="https://github.com/${REPO}/releases/download/v${version}/${asset_name}" + + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + echo "" + info "Phase CLI v${version} (${OS}/${ARCH})" + echo "" + + info "Downloading ${download_url}..." + fetch "$download_url" "${tmpdir}/${asset_name}" + + chmod +x "${tmpdir}/${asset_name}" + + # Ensure install directory exists + if [ ! -d "$INSTALL_DIR" ]; then + do_install mkdir -p "$INSTALL_DIR" fi - case $OS in - ubuntu|debian) - PACKAGE_URL="$BASE_URL/v$VERSION/phase_cli_linux_amd64_$VERSION.deb" - wget_download $PACKAGE_URL $TMPDIR/phase_cli_linux_amd64_$VERSION.deb - verify_checksum "$TMPDIR/phase_cli_linux_amd64_$VERSION.deb" "$PACKAGE_URL.sha256" - if [ "$EUID" -eq 0 ] || can_install_without_sudo; then - dpkg -i $TMPDIR/phase_cli_linux_amd64_$VERSION.deb - else - sudo dpkg -i $TMPDIR/phase_cli_linux_amd64_$VERSION.deb - fi - ;; - - fedora|rhel|centos|amzn|rocky) - PACKAGE_URL="$BASE_URL/v$VERSION/phase_cli_linux_amd64_$VERSION.rpm" - wget_download $PACKAGE_URL $TMPDIR/phase_cli_linux_amd64_$VERSION.rpm - verify_checksum "$TMPDIR/phase_cli_linux_amd64_$VERSION.rpm" "$PACKAGE_URL.sha256" - if [ "$EUID" -eq 0 ]; then - rpm -Uvh $TMPDIR/phase_cli_linux_amd64_$VERSION.rpm - else - echo "Installing RPM package. This may require sudo privileges." - sudo rpm -Uvh $TMPDIR/phase_cli_linux_amd64_$VERSION.rpm - fi - ;; - - alpine) - case $ARCH in - x86_64) - APK_ARCH="amd64" - ;; - aarch64) - APK_ARCH="arm64" - ;; - *) - echo "Unsupported architecture for Alpine: $ARCH" - exit 1 - ;; - esac - PACKAGE_URL="$BASE_URL/v$VERSION/phase_cli_linux_${APK_ARCH}_$VERSION.apk" - wget_download $PACKAGE_URL $TMPDIR/phase_cli_linux_${APK_ARCH}_$VERSION.apk - verify_checksum "$TMPDIR/phase_cli_linux_${APK_ARCH}_$VERSION.apk" "$PACKAGE_URL.sha256" - if [ "$EUID" -eq 0 ] || can_install_without_sudo; then - apk add --allow-untrusted $TMPDIR/phase_cli_linux_${APK_ARCH}_$VERSION.apk - else - sudo apk add --allow-untrusted $TMPDIR/phase_cli_linux_${APK_ARCH}_$VERSION.apk - fi - ;; - - *) - install_from_binary - ;; - esac - echo "phase-cli version $VERSION successfully installed" + info "Installing to ${INSTALL_DIR}/${BINARY_NAME}..." + do_install install -m 755 "${tmpdir}/${asset_name}" "${INSTALL_DIR}/${BINARY_NAME}" + + cleanup_legacy + + echo "" + info "Phase CLI v${version} installed successfully." + echo "" + + # Show the installed version + "${INSTALL_DIR}/${BINARY_NAME}" --version 2>/dev/null || true } +# --- Main --- + main() { - detect_os - check_required_tools - TMPDIR=$(mktemp -d) + detect_platform - # Default to the latest version unless a specific version is requested - VERSION=$(get_latest_version) + VERSION="" - # Parse command-line arguments while [ "$#" -gt 0 ]; do case "$1" in --version) VERSION="$2" shift 2 ;; + --help|-h) + echo "Usage: install.sh [--version VERSION]" + echo "" + echo "Install the Phase CLI. If no version is specified, the latest release is installed." + exit 0 + ;; *) - break + die "Unknown option: $1. Use --help for usage." ;; esac done - install_package + if [ -z "$VERSION" ]; then + info "Fetching latest version..." + VERSION=$(get_latest_version) + if [ -z "$VERSION" ]; then + die "Could not determine latest version. Specify one with --version" + fi + fi + + install_binary "$VERSION" } main "$@" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 00000000..5429362b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Cross-compile the Phase CLI for all supported platforms. +# Produces raw binaries — no archives, no checksums +# +# Usage (from repo root): +# scripts/build.sh +# VERSION=2.0.0 OUTPUT_DIR=./dist scripts/build.sh + +set -euo pipefail + +# Auto-detect the src/ directory relative to this script +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SRC_DIR="$REPO_ROOT/src" + +if [ ! -f "$SRC_DIR/go.mod" ]; then + echo "Error: Cannot find go.mod in $SRC_DIR" >&2 + exit 1 +fi + +VERSION="${VERSION:-dev}" +OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist}" + +# Resolve OUTPUT_DIR to absolute path +OUTPUT_DIR="$(cd "$(dirname "$OUTPUT_DIR")" 2>/dev/null && pwd)/$(basename "$OUTPUT_DIR")" || OUTPUT_DIR="$(pwd)/$OUTPUT_DIR" + +LDFLAGS="-s -w -X github.com/phasehq/cli/pkg/version.Version=${VERSION}" + +TARGETS=( + "linux/amd64" + "linux/arm64" + "darwin/amd64" + "darwin/arm64" + "windows/amd64" + "windows/arm64" + "freebsd/amd64" + "freebsd/arm64" + "openbsd/amd64" + "netbsd/amd64" + "linux/mips" + "linux/mipsle" + "linux/mips64" + "linux/mips64le" + "linux/riscv64" + "linux/ppc64le" + "linux/s390x" +) + +mkdir -p "$OUTPUT_DIR" + +echo "Building Phase CLI v${VERSION} for ${#TARGETS[@]} targets..." +echo "" + +cd "$SRC_DIR" + +FAILED=() + +for target in "${TARGETS[@]}"; do + os="${target%/*}" + arch="${target#*/}" + + output="phase-cli_${os}_${arch}" + if [ "$os" = "windows" ]; then + output="${output}.exe" + fi + + printf " %-24s" "${os}/${arch}" + + if CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \ + go build -trimpath -ldflags "$LDFLAGS" -o "${OUTPUT_DIR}/${output}" ./ 2>&1; then + echo "ok" + else + echo "FAILED" + FAILED+=("${os}/${arch}") + fi +done + +BUILT=$(find "$OUTPUT_DIR" -name 'phase-cli_*' | wc -l | tr -d ' ') +echo "" +echo "Done. ${BUILT}/${#TARGETS[@]} binaries in ${OUTPUT_DIR}/" + +if [ ${#FAILED[@]} -gt 0 ]; then + echo "" + echo "Failed targets:" + for f in "${FAILED[@]}"; do + echo " - $f" + done + exit 1 +fi diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 00000000..fe296242 --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# +# Package Phase CLI binaries into .deb, .rpm, .apk using FPM. +# Requires: fpm (gem install fpm) +# +# Usage (from repo root): +# scripts/package.sh +# VERSION=2.0.0 INPUT_DIR=dist scripts/package.sh + +set -euo pipefail + +VERSION="${VERSION:-dev}" +INPUT_DIR="${INPUT_DIR:-dist}" +OUTPUT_DIR="${OUTPUT_DIR:-dist/pkg}" + +PACKAGE_NAME="phase" +DESCRIPTION="Phase CLI - open-source secret manager" +MAINTAINER="Phase " +URL="https://phase.dev" +LICENSE="GPL-3.0" + +# Arch mappings: Go name → package manager name +pkg_arch() { + local fmt="$1" go_arch="$2" + case "$fmt" in + deb) echo "$go_arch" ;; # deb uses amd64/arm64 as-is + rpm|apk) + case "$go_arch" in + amd64) echo "x86_64" ;; + arm64) echo "aarch64" ;; + esac + ;; + esac +} + +TARGETS=("amd64" "arm64") + +if ! command -v fpm > /dev/null; then + echo "Error: fpm is not installed. Install with: gem install fpm" >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" + +echo "Packaging Phase CLI v${VERSION}..." +echo "" + +for arch in "${TARGETS[@]}"; do + binary="${INPUT_DIR}/phase-cli_linux_${arch}" + + if [ ! -f "$binary" ]; then + echo "Warning: $binary not found, skipping $arch" + continue + fi + + # Common flags (positional arg must come last, after per-format flags) + FPM_COMMON=( + -s dir + --name "$PACKAGE_NAME" + --version "$VERSION" + --maintainer "$MAINTAINER" + --description "$DESCRIPTION" + --url "$URL" + --license "$LICENSE" + --provides phase + --replaces "phase < 2.0.0" + --force + ) + + # .deb + printf " %-12s" "deb/${arch}" + fpm "${FPM_COMMON[@]}" -t deb \ + --architecture "$(pkg_arch deb "$arch")" \ + --package "${OUTPUT_DIR}/" \ + "${binary}=/usr/bin/phase" > /dev/null + echo "ok" + + # .rpm + printf " %-12s" "rpm/${arch}" + fpm "${FPM_COMMON[@]}" -t rpm \ + --architecture "$(pkg_arch rpm "$arch")" \ + --rpm-summary "$DESCRIPTION" \ + --package "${OUTPUT_DIR}/" \ + "${binary}=/usr/bin/phase" > /dev/null + echo "ok" + + # .apk + printf " %-12s" "apk/${arch}" + fpm "${FPM_COMMON[@]}" -t apk \ + --architecture "$(pkg_arch apk "$arch")" \ + --package "${OUTPUT_DIR}/" \ + "${binary}=/usr/bin/phase" > /dev/null + echo "ok" +done + +# Generate checksums +echo "" +if ls "$OUTPUT_DIR"/*.deb "$OUTPUT_DIR"/*.rpm "$OUTPUT_DIR"/*.apk > /dev/null; then + (cd "$OUTPUT_DIR" && sha256sum *.deb *.rpm *.apk > checksums.txt) + echo "Packages:" + ls -lh "$OUTPUT_DIR/" +else + echo "No packages were produced." + exit 1 +fi diff --git a/src/cmd/auth.go b/src/cmd/auth.go new file mode 100644 index 00000000..45129449 --- /dev/null +++ b/src/cmd/auth.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "syscall" + + "github.com/manifoldco/promptui" + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "šŸ’» Authenticate with Phase", + RunE: runAuth, +} + +var authMode string + +func init() { + authCmd.Flags().StringVar(&authMode, "mode", "webauth", "Authentication mode (webauth, token, aws-iam)") + authCmd.Flags().String("service-account-id", "", "Service account ID (required for aws-iam mode)") + authCmd.Flags().Int("ttl", 0, "Token TTL in seconds (for aws-iam mode)") + authCmd.Flags().Bool("no-store", false, "Print token to stdout instead of storing (for aws-iam mode)") + rootCmd.AddCommand(authCmd) +} + +func runAuth(cmd *cobra.Command, args []string) error { + // Determine host + host := os.Getenv("PHASE_HOST") + if host == "" { + prompt := promptui.Select{ + Label: "Choose your Phase instance type", + Items: []string{"ā˜ļø Phase Cloud", "šŸ› ļø Self Hosted"}, + } + idx, _, err := prompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + + if idx == 1 { + hostPrompt := promptui.Prompt{ + Label: "Please enter your host (URL eg. https://example.com)", + } + host, err = hostPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + host = strings.TrimSpace(host) + if host == "" { + return fmt.Errorf("host URL is required for self-hosted instances") + } + if !util.ValidateURL(host) { + return fmt.Errorf("invalid URL. Please ensure you include the scheme (e.g., https) and domain. Keep in mind, path and port are optional") + } + } else { + host = config.PhaseCloudAPIHost + } + } else { + fmt.Fprintf(os.Stderr, "Using PHASE_HOST environment variable: %s\n", host) + } + + switch authMode { + case "webauth": + return runWebAuth(cmd, host) + case "aws-iam": + return runAWSIAMAuth(cmd, host) + case "token": + return runTokenAuth(cmd, host) + default: + return fmt.Errorf("unsupported auth mode: %s. Supported modes: token, webauth, aws-iam", authMode) + } +} + +func runTokenAuth(cmd *cobra.Command, host string) error { + // Get token + fmt.Print("Please enter Personal Access Token (PAT) or Service Account Token (hidden): ") + tokenBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read token: %w", err) + } + fmt.Println() + authToken := strings.TrimSpace(string(tokenBytes)) + if authToken == "" { + return fmt.Errorf("token is required") + } + + isPersonalToken := strings.HasPrefix(authToken, "pss_user:") + var userEmail string + if isPersonalToken { + emailPrompt := promptui.Prompt{ + Label: "Please enter your email", + } + userEmail, err = emailPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + userEmail = strings.TrimSpace(userEmail) + if userEmail == "" { + return fmt.Errorf("email is required for personal access tokens") + } + } + + // Validate token + p, err := phase.NewPhase(false, authToken, host) + if err != nil { + return fmt.Errorf("invalid token: %w", err) + } + + if err := phase.Auth(p); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + // Get user data + userData, err := phase.Init(p) + if err != nil { + return fmt.Errorf("failed to fetch user data: %w", err) + } + + accountID, err := phase.AccountID(userData) + if err != nil { + return err + } + + var orgID, orgName *string + if userData.Organisation != nil { + orgID = &userData.Organisation.ID + orgName = &userData.Organisation.Name + } + + var wrappedKeyShare *string + if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + wrappedKeyShare = &userData.WrappedKeyShare + } + + // Save credentials to keyring + tokenSavedInKeyring := true + if err := keyring.SetCredentials(accountID, authToken); err != nil { + tokenSavedInKeyring = false + } + + // Build user config + userConfig := config.UserConfig{ + Host: host, + ID: accountID, + OrganizationID: orgID, + OrganizationName: orgName, + WrappedKeyShare: wrappedKeyShare, + } + if userEmail != "" { + userConfig.Email = userEmail + } + if !tokenSavedInKeyring { + userConfig.Token = authToken + } + + // Save to config + if err := config.AddUser(userConfig); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println(util.BoldGreen("āœ… Authentication successful.")) + return nil +} diff --git a/src/cmd/auth_aws.go b/src/cmd/auth_aws.go new file mode 100644 index 00000000..59acee2e --- /dev/null +++ b/src/cmd/auth_aws.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/phasehq/golang-sdk/phase/network" + "github.com/spf13/cobra" +) + +func resolveRegionAndEndpoint(ctx context.Context) (string, string, error) { + // Load the full AWS SDK config which reads env vars, ~/.aws/config, + // EC2 IMDS, etc. — matching boto3's region resolution behavior. + cfg, err := awsconfig.LoadDefaultConfig(ctx) + if err != nil { + return "", "", fmt.Errorf("failed to load AWS config: %w", err) + } + + region := cfg.Region + if region == "" { + region = "us-east-1" + } + + endpoint := fmt.Sprintf("https://sts.%s.amazonaws.com", region) + if region == "us-east-1" { + endpoint = "https://sts.amazonaws.com" + } + + return region, endpoint, nil +} + +func signGetCallerIdentity(ctx context.Context, region, endpoint string) (string, map[string]string, string, error) { + body := "Action=GetCallerIdentity&Version=2011-06-15" + + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return "", nil, "", fmt.Errorf("failed to load AWS config: %w", err) + } + + creds, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + return "", nil, "", fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + + req, err := http.NewRequest("POST", endpoint, strings.NewReader(body)) + if err != nil { + return "", nil, "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + + // Compute SHA256 hash of the body for SigV4 + bodyHash := sha256.Sum256([]byte(body)) + payloadHash := hex.EncodeToString(bodyHash[:]) + + signer := v4.NewSigner() + err = signer.SignHTTP(ctx, creds, req, payloadHash, "sts", region, time.Now()) + if err != nil { + return "", nil, "", fmt.Errorf("failed to sign request: %w", err) + } + + signedHeaders := map[string]string{} + for key, values := range req.Header { + if len(values) > 0 { + signedHeaders[key] = values[0] + } + } + + return endpoint, signedHeaders, body, nil +} + +func runAWSIAMAuth(cmd *cobra.Command, host string) error { + serviceAccountID, _ := cmd.Flags().GetString("service-account-id") + if serviceAccountID == "" { + return fmt.Errorf("--service-account-id is required for aws-iam auth mode") + } + + ttlVal, _ := cmd.Flags().GetInt("ttl") + var ttl *int + if cmd.Flags().Changed("ttl") { + ttl = &ttlVal + } + + noStore, _ := cmd.Flags().GetBool("no-store") + + ctx := context.Background() + region, endpoint, err := resolveRegionAndEndpoint(ctx) + if err != nil { + return fmt.Errorf("failed to resolve AWS region: %w", err) + } + + signedURL, signedHeaders, body, err := signGetCallerIdentity(ctx, region, endpoint) + if err != nil { + return fmt.Errorf("failed to sign AWS request: %w", err) + } + + // Base64 encode the signed values + encodedURL := base64.StdEncoding.EncodeToString([]byte(signedURL)) + headersJSON, _ := json.Marshal(signedHeaders) + encodedHeaders := base64.StdEncoding.EncodeToString(headersJSON) + encodedBody := base64.StdEncoding.EncodeToString([]byte(body)) + + result, err := network.ExternalIdentityAuthAWS(host, serviceAccountID, ttl, encodedURL, encodedHeaders, encodedBody, "POST") + if err != nil { + return fmt.Errorf("AWS IAM authentication failed: %w", err) + } + + if noStore { + output, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(output)) + return nil + } + + // Extract token from response + auth, ok := result["authentication"].(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected response format: missing 'authentication' field") + } + token, ok := auth["token"].(string) + if !ok || token == "" { + return fmt.Errorf("no token found in authentication response") + } + + // Validate the token + p, err := phase.NewPhase(false, token, host) + if err != nil { + return fmt.Errorf("invalid token received: %w", err) + } + + if err := phase.Auth(p); err != nil { + return fmt.Errorf("token validation failed: %w", err) + } + + // Get user data + userData, err := phase.Init(p) + if err != nil { + return fmt.Errorf("failed to fetch user data: %w", err) + } + + accountID, err := phase.AccountID(userData) + if err != nil { + return err + } + + var orgID, orgName *string + if userData.Organisation != nil { + orgID = &userData.Organisation.ID + orgName = &userData.Organisation.Name + } + + var wrappedKeyShare *string + if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + wrappedKeyShare = &userData.WrappedKeyShare + } + + tokenSavedInKeyring := true + if err := keyring.SetCredentials(accountID, token); err != nil { + tokenSavedInKeyring = false + } + + userConfig := config.UserConfig{ + Host: host, + ID: accountID, + OrganizationID: orgID, + OrganizationName: orgName, + WrappedKeyShare: wrappedKeyShare, + } + if !tokenSavedInKeyring { + userConfig.Token = token + } + + if err := config.AddUser(userConfig); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println(util.BoldGreen("āœ… Authentication successful.")) + return nil +} diff --git a/src/cmd/auth_webauth.go b/src/cmd/auth_webauth.go new file mode 100644 index 00000000..dfb861cf --- /dev/null +++ b/src/cmd/auth_webauth.go @@ -0,0 +1,185 @@ +package cmd + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "math/rand" + "net" + "net/http" + "os" + "os/user" + "strings" + "time" + + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/phasehq/golang-sdk/phase/crypto" + "github.com/spf13/cobra" +) + +func runWebAuth(cmd *cobra.Command, host string) error { + // Pick random port + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + port := 8000 + rng.Intn(12001) + + // Generate ephemeral keypair + kp, err := crypto.RandomKeyPair() + if err != nil { + return fmt.Errorf("failed to generate keypair: %w", err) + } + + pubKeyHex := hex.EncodeToString(kp.PublicKey[:]) + privKeyHex := hex.EncodeToString(kp.SecretKey[:]) + + // Build PAT name + username := "unknown" + if u, err := user.Current(); err == nil { + username = u.Username + } + hostname, _ := os.Hostname() + patName := fmt.Sprintf("%s@%s", username, hostname) + + // Encode payload + rawData := fmt.Sprintf("%d-%s-%s", port, pubKeyHex, patName) + encoded := base64.StdEncoding.EncodeToString([]byte(rawData)) + + // Channel to receive auth data + type authData struct { + Email string `json:"email"` + PSS string `json:"pss"` + } + dataCh := make(chan authData, 1) + + // Create listener + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return fmt.Errorf("failed to start server on port %d: %w", port, err) + } + + // Set up HTTP handler + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + origin := host + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + if r.Method == "POST" { + var data authData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "Success: CLI authentication complete", + }) + dataCh <- data + } + }) + + server := &http.Server{Handler: mux} + go server.Serve(listener) + + // Open browser + authURL := fmt.Sprintf("%s/webauth/%s", host, encoded) + fmt.Fprintf(os.Stderr, "Opening browser for authentication...\n") + if err := util.OpenBrowser(authURL); err != nil { + fmt.Fprintf(os.Stderr, "Please open this URL in your browser:\n%s\n", authURL) + } + + // Wait for data + fmt.Fprintf(os.Stderr, "Waiting for authentication...\n") + var received authData + select { + case received = <-dataCh: + case <-time.After(5 * time.Minute): + server.Close() + return fmt.Errorf("authentication timed out") + } + + // Shut down server + server.Close() + + // Decrypt email and PSS + decryptedEmail, err := crypto.DecryptAsymmetric(received.Email, privKeyHex, pubKeyHex) + if err != nil { + return fmt.Errorf("failed to decrypt email: %w", err) + } + + decryptedPSS, err := crypto.DecryptAsymmetric(received.PSS, privKeyHex, pubKeyHex) + if err != nil { + return fmt.Errorf("failed to decrypt token: %w", err) + } + + authToken := strings.TrimSpace(decryptedPSS) + userEmail := strings.TrimSpace(decryptedEmail) + + // Validate token + p, err := phase.NewPhase(false, authToken, host) + if err != nil { + return fmt.Errorf("invalid token: %w", err) + } + + if err := phase.Auth(p); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + // Get user data + userData, err := phase.Init(p) + if err != nil { + return fmt.Errorf("failed to fetch user data: %w", err) + } + + accountID, err := phase.AccountID(userData) + if err != nil { + return err + } + + var orgID, orgName *string + if userData.Organisation != nil { + orgID = &userData.Organisation.ID + orgName = &userData.Organisation.Name + } + + var wrappedKeyShare *string + if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + wrappedKeyShare = &userData.WrappedKeyShare + } + + // Save credentials to keyring + tokenSavedInKeyring := true + if err := keyring.SetCredentials(accountID, authToken); err != nil { + tokenSavedInKeyring = false + } + + // Build user config + userConfig := config.UserConfig{ + Host: host, + ID: accountID, + Email: userEmail, + OrganizationID: orgID, + OrganizationName: orgName, + WrappedKeyShare: wrappedKeyShare, + } + if !tokenSavedInKeyring { + userConfig.Token = authToken + } + + // Save to config + if err := config.AddUser(userConfig); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println(util.BoldGreen("āœ… Authentication successful.")) + return nil +} diff --git a/src/cmd/console.go b/src/cmd/console.go new file mode 100644 index 00000000..0a1ed572 --- /dev/null +++ b/src/cmd/console.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var consoleCmd = &cobra.Command{ + Use: "console", + Short: "šŸ–„ļø\u200A Open the Phase Console in your browser", + RunE: runConsole, +} + +func init() { + rootCmd.AddCommand(consoleCmd) +} + +func runConsole(cmd *cobra.Command, args []string) error { + user, err := config.GetDefaultUser() + if err != nil { + return fmt.Errorf("no user configured: %w", err) + } + + host := user.Host + orgName := "" + if user.OrganizationName != nil { + orgName = *user.OrganizationName + } + + phaseConfig := config.FindPhaseConfig(8) + if phaseConfig != nil && orgName != "" { + version := 1 + if phaseConfig.Version != "" { + if v, err := strconv.Atoi(phaseConfig.Version); err == nil { + version = v + } + } + + if version >= 2 && phaseConfig.EnvID != "" { + url := fmt.Sprintf("%s/%s/apps/%s/environments/%s", host, orgName, phaseConfig.AppID, phaseConfig.EnvID) + fmt.Printf("Opening %s\n", url) + return util.OpenBrowser(url) + } + + url := fmt.Sprintf("%s/%s/apps/%s", host, orgName, phaseConfig.AppID) + fmt.Printf("Opening %s\n", url) + return util.OpenBrowser(url) + } + + fmt.Printf("Opening %s\n", host) + return util.OpenBrowser(host) +} diff --git a/src/cmd/docs.go b/src/cmd/docs.go new file mode 100644 index 00000000..5188c57f --- /dev/null +++ b/src/cmd/docs.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var docsCmd = &cobra.Command{ + Use: "docs", + Short: "šŸ“– Open the Phase CLI Docs in your browser", + RunE: runDocs, +} + +func init() { + rootCmd.AddCommand(docsCmd) +} + +func runDocs(cmd *cobra.Command, args []string) error { + url := "https://docs.phase.dev/cli/commands" + fmt.Printf("Opening %s\n", url) + return util.OpenBrowser(url) +} diff --git a/src/cmd/dynamic_secrets.go b/src/cmd/dynamic_secrets.go new file mode 100644 index 00000000..6c5febd7 --- /dev/null +++ b/src/cmd/dynamic_secrets.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var dynamicSecretsCmd = &cobra.Command{ + Use: "dynamic-secrets", + Short: "āš”ļø Manage dynamic secrets", +} + +var dynamicSecretsLeaseCmd = &cobra.Command{ + Use: "lease", + Short: "šŸ“œ Manage dynamic secret leases", +} + +func init() { + dynamicSecretsCmd.AddCommand(dynamicSecretsLeaseCmd) + rootCmd.AddCommand(dynamicSecretsCmd) +} diff --git a/src/cmd/dynamic_secrets_lease_generate.go b/src/cmd/dynamic_secrets_lease_generate.go new file mode 100644 index 00000000..7c1dd70a --- /dev/null +++ b/src/cmd/dynamic_secrets_lease_generate.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/phasehq/golang-sdk/phase/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsLeaseGenerateCmd = &cobra.Command{ + Use: "generate ", + Short: "✨ Generate a lease (create fresh dynamic secret)", + Args: cobra.ExactArgs(1), + RunE: runDynamicSecretsLeaseGenerate, +} + +func init() { + dynamicSecretsLeaseGenerateCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + dynamicSecretsLeaseGenerateCmd.Flags().String("env", "", "Environment name") + dynamicSecretsLeaseGenerateCmd.Flags().String("app", "", "Application name") + dynamicSecretsLeaseGenerateCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsLeaseCmd.AddCommand(dynamicSecretsLeaseGenerateCmd) +} + +func runDynamicSecretsLeaseGenerate(cmd *cobra.Command, args []string) error { + secretID := args[0] + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := phase.Init(p) + if err != nil { + return err + } + + _, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + + var ttlPtr *int + if cmd.Flags().Changed("lease-ttl") { + ttlPtr = &leaseTTL + } + + result, err := network.CreateDynamicSecretLease(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, secretID, ttlPtr) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/dynamic_secrets_lease_get.go b/src/cmd/dynamic_secrets_lease_get.go new file mode 100644 index 00000000..fd0ecf65 --- /dev/null +++ b/src/cmd/dynamic_secrets_lease_get.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/phasehq/golang-sdk/phase/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsLeaseGetCmd = &cobra.Command{ + Use: "get ", + Short: "šŸ” Get leases for a dynamic secret", + Args: cobra.ExactArgs(1), + RunE: runDynamicSecretsLeaseGet, +} + +func init() { + dynamicSecretsLeaseGetCmd.Flags().String("env", "", "Environment name") + dynamicSecretsLeaseGetCmd.Flags().String("app", "", "Application name") + dynamicSecretsLeaseGetCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsLeaseCmd.AddCommand(dynamicSecretsLeaseGetCmd) +} + +func runDynamicSecretsLeaseGet(cmd *cobra.Command, args []string) error { + secretID := args[0] + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := phase.Init(p) + if err != nil { + return err + } + + _, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + + result, err := network.ListDynamicSecretLeases(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, secretID) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/dynamic_secrets_lease_renew.go b/src/cmd/dynamic_secrets_lease_renew.go new file mode 100644 index 00000000..50fb8c6c --- /dev/null +++ b/src/cmd/dynamic_secrets_lease_renew.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/phasehq/golang-sdk/phase/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsLeaseRenewCmd = &cobra.Command{ + Use: "renew ", + Short: "šŸ” Renew a lease", + Args: cobra.ExactArgs(2), + RunE: runDynamicSecretsLeaseRenew, +} + +func init() { + dynamicSecretsLeaseRenewCmd.Flags().String("env", "", "Environment name") + dynamicSecretsLeaseRenewCmd.Flags().String("app", "", "Application name") + dynamicSecretsLeaseRenewCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsLeaseCmd.AddCommand(dynamicSecretsLeaseRenewCmd) +} + +func runDynamicSecretsLeaseRenew(cmd *cobra.Command, args []string) error { + leaseID := args[0] + ttl, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid TTL value: %s", args[1]) + } + + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := phase.Init(p) + if err != nil { + return err + } + + _, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + + result, err := network.RenewDynamicSecretLease(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, leaseID, ttl) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/dynamic_secrets_lease_revoke.go b/src/cmd/dynamic_secrets_lease_revoke.go new file mode 100644 index 00000000..f54453f5 --- /dev/null +++ b/src/cmd/dynamic_secrets_lease_revoke.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/phasehq/golang-sdk/phase/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsLeaseRevokeCmd = &cobra.Command{ + Use: "revoke ", + Short: "šŸ—‘ļø\u200A Revoke a lease", + Args: cobra.ExactArgs(1), + RunE: runDynamicSecretsLeaseRevoke, +} + +func init() { + dynamicSecretsLeaseRevokeCmd.Flags().String("env", "", "Environment name") + dynamicSecretsLeaseRevokeCmd.Flags().String("app", "", "Application name") + dynamicSecretsLeaseRevokeCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsLeaseCmd.AddCommand(dynamicSecretsLeaseRevokeCmd) +} + +func runDynamicSecretsLeaseRevoke(cmd *cobra.Command, args []string) error { + leaseID := args[0] + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := phase.Init(p) + if err != nil { + return err + } + + _, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + + result, err := network.RevokeDynamicSecretLease(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, leaseID) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/dynamic_secrets_list.go b/src/cmd/dynamic_secrets_list.go new file mode 100644 index 00000000..a15a0c7b --- /dev/null +++ b/src/cmd/dynamic_secrets_list.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/phasehq/golang-sdk/phase/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsListCmd = &cobra.Command{ + Use: "list", + Short: "šŸ“‡ List dynamic secrets & metadata", + RunE: runDynamicSecretsList, +} + +func init() { + dynamicSecretsListCmd.Flags().String("env", "", "Environment name") + dynamicSecretsListCmd.Flags().String("app", "", "Application name") + dynamicSecretsListCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsListCmd.Flags().String("path", "/", "Path filter") + dynamicSecretsCmd.AddCommand(dynamicSecretsListCmd) +} + +func runDynamicSecretsList(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := phase.Init(p) + if err != nil { + return err + } + + resolvedAppName, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + _ = resolvedAppName + + result, err := network.ListDynamicSecrets(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, path) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/init_cmd.go b/src/cmd/init_cmd.go new file mode 100644 index 00000000..e25410cc --- /dev/null +++ b/src/cmd/init_cmd.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + + "github.com/manifoldco/promptui" + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "šŸ”— Link your project with your Phase app", + RunE: runInit, +} + +func init() { + rootCmd.AddCommand(initCmd) +} + +func runInit(cmd *cobra.Command, args []string) error { + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + data, err := phase.Init(p) + if err != nil { + return err + } + + if len(data.Apps) == 0 { + return fmt.Errorf("no applications found") + } + + // Build app choice labels + appItems := make([]string, len(data.Apps)+1) + for i, app := range data.Apps { + appItems[i] = fmt.Sprintf("%s (%s)", app.Name, app.ID) + } + appItems[len(data.Apps)] = "Exit" + + appPrompt := promptui.Select{ + Label: "Select an App", + Items: appItems, + } + appIdx, _, err := appPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + if appIdx == len(data.Apps) { + return nil + } + + selectedApp := data.Apps[appIdx] + + // Sort environments + envSortOrder := map[string]int{"DEV": 1, "STAGING": 2, "PROD": 3} + envKeys := make([]struct { + idx int + sort int + }, len(selectedApp.EnvironmentKeys)) + for i, ek := range selectedApp.EnvironmentKeys { + order, ok := envSortOrder[ek.Environment.EnvType] + if !ok { + order = 4 + } + envKeys[i] = struct { + idx int + sort int + }{i, order} + } + sort.Slice(envKeys, func(i, j int) bool { + return envKeys[i].sort < envKeys[j].sort + }) + + // Build env choice labels + envItems := make([]string, len(envKeys)+1) + for i, ek := range envKeys { + env := selectedApp.EnvironmentKeys[ek.idx] + envItems[i] = env.Environment.Name + } + envItems[len(envKeys)] = "Exit" + + envPrompt := promptui.Select{ + Label: "Choose a Default Environment", + Items: envItems, + } + envIdx, _, err := envPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + if envIdx == len(envKeys) { + return nil + } + + selectedEnvKey := selectedApp.EnvironmentKeys[envKeys[envIdx].idx] + + // Ask about monorepo support + monorepoPrompt := promptui.Select{ + Label: "šŸ± Monorepo support: Would you like this configuration to apply to subdirectories?", + Items: []string{"No", "Yes"}, + } + monorepoIdx, _, err := monorepoPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + monorepoSupport := monorepoIdx == 1 + + // Write .phase.json + phaseConfig := &config.PhaseJSONConfig{ + Version: "2", + PhaseApp: selectedApp.Name, + AppID: selectedApp.ID, + DefaultEnv: selectedEnvKey.Environment.Name, + EnvID: selectedEnvKey.Environment.ID, + MonorepoSupport: monorepoSupport, + } + + if err := config.WritePhaseConfig(phaseConfig); err != nil { + return fmt.Errorf("failed to write .phase.json: %w", err) + } + + // Set file permissions + os.Chmod(config.PhaseEnvConfig, 0600) + + fmt.Println(util.BoldGreen("āœ… Initialization completed successfully.")) + return nil +} diff --git a/src/cmd/root.go b/src/cmd/root.go new file mode 100644 index 00000000..265cb76c --- /dev/null +++ b/src/cmd/root.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + "os" + + phaseerrors "github.com/phasehq/cli/pkg/errors" + "github.com/phasehq/cli/pkg/version" + "github.com/spf13/cobra" +) + +const phaseASCii = ` + /$$ + | $$ + /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ + /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$ + | $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$ + | $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/ + | $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$ + | $$____/ |__/ |__/ \_______/|_______/ \_______/ + | $$ + |__/ +` + +const description = "Keep Secrets." + +var rootCmd = &cobra.Command{ + Use: "phase", + Short: description, + Long: description + "\n" + phaseASCii, + SilenceUsage: true, + SilenceErrors: true, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", phaseerrors.FormatSDKError(err)) + os.Exit(1) + } +} + +func init() { + rootCmd.Version = version.Version + rootCmd.SetVersionTemplate("{{ .Version }}\n") + + // Add emojis to built-in cobra commands + rootCmd.InitDefaultCompletionCmd() + if completionCmd, _, _ := rootCmd.Find([]string{"completion"}); completionCmd != nil { + completionCmd.Short = "āŒØļø\u200A\u200A" + completionCmd.Short + } + rootCmd.InitDefaultHelpCmd() + if helpCmd, _, _ := rootCmd.Find([]string{"help"}); helpCmd != nil { + helpCmd.Short = "🤷\u200A" + helpCmd.Short + } +} diff --git a/src/cmd/run.go b/src/cmd/run.go new file mode 100644 index 00000000..5b2cc91c --- /dev/null +++ b/src/cmd/run.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/spf13/cobra" +) + +var runCmd = &cobra.Command{ + Use: "run ", + Short: "šŸš€ Run and inject secrets to your app", + Args: cobra.MinimumNArgs(1), + DisableFlagParsing: false, + RunE: runRun, +} + +func init() { + runCmd.Flags().String("env", "", "Environment name") + runCmd.Flags().String("app", "", "Application name") + runCmd.Flags().String("app-id", "", "Application ID") + runCmd.Flags().String("tags", "", "Filter by tags") + runCmd.Flags().String("path", "/", "Path filter") + runCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") + runCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + rootCmd.AddCommand(runCmd) +} + +func runRun(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + tags, _ := cmd.Flags().GetString("tags") + path, _ := cmd.Flags().GetString("path") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + + appName, envName, appID = phase.GetConfig(appName, envName, appID) + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + // Fetch secrets + opts := sdk.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Tag: tags, + Path: path, + Dynamic: true, + Lease: util.ParseBoolFlag(generateLeases), + } + if cmd.Flags().Changed("lease-ttl") { + opts.LeaseTTL = &leaseTTL + } + + spinner := util.NewSpinner("Fetching secrets...") + spinner.Start() + allSecrets, err := p.Get(opts) + spinner.Stop() + if err != nil { + return err + } + + resolvedSecrets := map[string]string{} + for _, secret := range allSecrets { + if secret.Value == "" { + continue + } + resolvedSecrets[secret.Key] = secret.Value + } + + // Print injection stats to stderr (matches Python CLI behavior) + secretCount := len(resolvedSecrets) + apps := map[string]bool{} + envs := map[string]bool{} + for _, s := range allSecrets { + if _, ok := resolvedSecrets[s.Key]; ok { + if s.Application != "" { + apps[s.Application] = true + } + envs[s.Environment] = true + } + } + appNames := mapKeys(apps) + envNames := mapKeys(envs) + + if path != "" && path != "/" { + fmt.Fprintf(os.Stderr, "šŸš€ Injected %s secrets from Application: %s, Environment: %s, Path: %s\n", + util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)), + util.BoldCyanErr(strings.Join(appNames, ", ")), + util.BoldGreenErr(strings.Join(envNames, ", ")), + util.BoldYellowErr(path)) + } else { + fmt.Fprintf(os.Stderr, "šŸš€ Injected %s secrets from Application: %s, Environment: %s\n", + util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)), + util.BoldCyanErr(strings.Join(appNames, ", ")), + util.BoldGreenErr(strings.Join(envNames, ", "))) + } + + // Build environment: inherit current env and append secrets + envSlice := os.Environ() + for k, v := range resolvedSecrets { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + + // Execute command + command := strings.Join(args, " ") + shell := util.GetDefaultShell() + var c *exec.Cmd + if len(shell) > 0 { + c = exec.Command(shell[0], "-c", command) + } else { + c = exec.Command("sh", "-c", command) + } + c.Env = envSlice + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + + if err := c.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return err + } + return nil +} + +func mapKeys(m map[string]bool) []string { + var keys []string + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/src/cmd/run_shell_test.go b/src/cmd/run_shell_test.go new file mode 100644 index 00000000..2b660cfa --- /dev/null +++ b/src/cmd/run_shell_test.go @@ -0,0 +1,30 @@ +package cmd + +import "testing" + +func TestRunCommandRequiresAtLeastOneArg(t *testing.T) { + if err := runCmd.Args(runCmd, []string{}); err == nil { + t.Fatal("expected error when no command args are provided") + } + if err := runCmd.Args(runCmd, []string{"echo"}); err != nil { + t.Fatalf("expected no error for one arg, got %v", err) + } +} + +func TestRunAndShellDefaultPathFlag(t *testing.T) { + runPath, err := runCmd.Flags().GetString("path") + if err != nil { + t.Fatalf("read run --path flag: %v", err) + } + if runPath != "/" { + t.Fatalf("unexpected run --path default: got %q want %q", runPath, "/") + } + + shellPath, err := shellCmd.Flags().GetString("path") + if err != nil { + t.Fatalf("read shell --path flag: %v", err) + } + if shellPath != "/" { + t.Fatalf("unexpected shell --path default: got %q want %q", shellPath, "/") + } +} diff --git a/src/cmd/secrets.go b/src/cmd/secrets.go new file mode 100644 index 00000000..d3e59970 --- /dev/null +++ b/src/cmd/secrets.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var secretsCmd = &cobra.Command{ + Use: "secrets", + Short: "šŸ—ļø\u200A Manage your secrets", +} + +func init() { + rootCmd.AddCommand(secretsCmd) +} diff --git a/src/cmd/secrets_create.go b/src/cmd/secrets_create.go new file mode 100644 index 00000000..4c527899 --- /dev/null +++ b/src/cmd/secrets_create.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "syscall" + + "github.com/phasehq/cli/pkg/phase" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/phasehq/golang-sdk/phase/misc" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var secretsCreateCmd = &cobra.Command{ + Use: "create [KEY]", + Short: "šŸ’³ Create a new secret", + Args: cobra.MaximumNArgs(1), + RunE: runSecretsCreate, +} + +func init() { + secretsCreateCmd.Flags().String("env", "", "Environment name") + secretsCreateCmd.Flags().String("app", "", "Application name") + secretsCreateCmd.Flags().String("app-id", "", "Application ID") + secretsCreateCmd.Flags().String("path", "/", "Path for the secret") + secretsCreateCmd.Flags().Bool("override", false, "Create with override") + secretsCreateCmd.Flags().String("random", "", "Random type (hex, alphanumeric, base64, base64url, key128, key256)") + secretsCreateCmd.Flags().Int("length", 32, "Length for random secret") + secretsCmd.AddCommand(secretsCreateCmd) +} + +func runSecretsCreate(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + override, _ := cmd.Flags().GetBool("override") + randomType, _ := cmd.Flags().GetString("random") + randomLength, _ := cmd.Flags().GetInt("length") + + var key string + if len(args) > 0 { + key = args[0] + } else { + fmt.Print("šŸ—ļø\u200A Please enter the key: ") + fmt.Scanln(&key) + } + + key = strings.ReplaceAll(key, " ", "_") + key = strings.ToUpper(key) + + var value string + if override { + value = "" + } else if randomType != "" { + validTypes := map[string]bool{"hex": true, "alphanumeric": true, "base64": true, "base64url": true, "key128": true, "key256": true} + if !validTypes[randomType] { + return fmt.Errorf("unsupported random type: %s. Supported types: hex, alphanumeric, base64, base64url, key128, key256", randomType) + } + if (randomType == "key128" || randomType == "key256") && randomLength != 32 { + fmt.Fprintf(os.Stderr, "āš ļø\u200A Warning: The length argument is ignored for '%s'. Using default lengths.\n", randomType) + } + var err error + value, err = misc.GenerateRandomSecret(randomType, randomLength) + if err != nil { + return fmt.Errorf("failed to generate random secret: %w", err) + } + } else { + if term.IsTerminal(int(syscall.Stdin)) { + fmt.Print("✨ Please enter the value (hidden): ") + valueBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read value: %w", err) + } + fmt.Println() + value = string(valueBytes) + } else { + // Read from pipe + buf := make([]byte, 1024*1024) + n, _ := os.Stdin.Read(buf) + value = strings.TrimSpace(string(buf[:n])) + } + } + + var overrideValue string + if override { + fmt.Print("✨ Please enter the šŸ” override value (hidden): ") + ovBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read override value: %w", err) + } + fmt.Println() + overrideValue = string(ovBytes) + } + + appName, envName, appID = phase.GetConfig(appName, envName, appID) + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + err = p.Create(sdk.CreateOptions{ + KeyValuePairs: []sdk.KeyValuePair{{Key: key, Value: value}}, + EnvName: envName, + AppName: appName, + AppID: appID, + Path: path, + OverrideValue: overrideValue, + }) + if err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + + if err := listSecrets(p, envName, appName, appID, "", path, false, false, false, nil); err != nil { + return err + } + return nil +} diff --git a/src/cmd/secrets_delete.go b/src/cmd/secrets_delete.go new file mode 100644 index 00000000..dfe1eb2e --- /dev/null +++ b/src/cmd/secrets_delete.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/spf13/cobra" +) + +var secretsDeleteCmd = &cobra.Command{ + Use: "delete [KEYS...]", + Short: "šŸ—‘ļø\u200A Delete a secret", + RunE: runSecretsDelete, +} + +func init() { + secretsDeleteCmd.Flags().String("env", "", "Environment name") + secretsDeleteCmd.Flags().String("app", "", "Application name") + secretsDeleteCmd.Flags().String("app-id", "", "Application ID") + secretsDeleteCmd.Flags().String("path", "/", "Path filter (default '/'. Pass empty string to delete from all paths)") + secretsCmd.AddCommand(secretsDeleteCmd) +} + +func runSecretsDelete(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + + keysToDelete := args + if len(keysToDelete) == 0 { + fmt.Print("Please enter the keys to delete (separate multiple keys with a space): ") + var input string + fmt.Scanln(&input) + keysToDelete = strings.Fields(input) + } + + // Uppercase keys + for i, k := range keysToDelete { + keysToDelete[i] = strings.ToUpper(k) + } + + appName, envName, appID = phase.GetConfig(appName, envName, appID) + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + keysNotFound, err := p.Delete(sdk.DeleteOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + KeysToDelete: keysToDelete, + Path: path, + }) + if err != nil { + return fmt.Errorf("failed to delete secrets: %w", err) + } + + if len(keysNotFound) > 0 { + fmt.Fprintf(os.Stderr, "āš ļø Warning: The following keys were not found: %s\n", strings.Join(keysNotFound, ", ")) + } else { + fmt.Println(util.BoldGreen("āœ… Successfully deleted the secrets.")) + } + + if err := listSecrets(p, envName, appName, appID, "", path, false, false, false, nil); err != nil { + return err + } + return nil +} diff --git a/src/cmd/secrets_export.go b/src/cmd/secrets_export.go new file mode 100644 index 00000000..1b29db7a --- /dev/null +++ b/src/cmd/secrets_export.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/spf13/cobra" +) + +var secretsExportCmd = &cobra.Command{ + Use: "export [keys...]", + Short: "🄔 Export secrets in a specific format", + RunE: runSecretsExport, +} + +func init() { + secretsExportCmd.Flags().String("format", "dotenv", "Export format (dotenv, json, csv, yaml, xml, toml, hcl, ini, java_properties, kv)") + secretsExportCmd.Flags().String("env", "", "Environment name") + secretsExportCmd.Flags().String("app", "", "Application name") + secretsExportCmd.Flags().String("app-id", "", "Application ID") + secretsExportCmd.Flags().String("tags", "", "Filter by tags") + secretsExportCmd.Flags().String("path", "/", "Path filter (default '/'. Pass empty string to export from all paths)") + secretsExportCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") + secretsExportCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + secretsCmd.AddCommand(secretsExportCmd) +} + +func runSecretsExport(cmd *cobra.Command, args []string) error { + format, _ := cmd.Flags().GetString("format") + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + tags, _ := cmd.Flags().GetString("tags") + path, _ := cmd.Flags().GetString("path") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + + // Uppercase requested keys for filtering + var filterKeys []string + for _, k := range args { + filterKeys = append(filterKeys, strings.ToUpper(k)) + } + + appName, envName, appID = phase.GetConfig(appName, envName, appID) + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + opts := sdk.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Tag: tags, + Path: path, + Dynamic: true, + Lease: util.ParseBoolFlag(generateLeases), + } + if cmd.Flags().Changed("lease-ttl") { + opts.LeaseTTL = &leaseTTL + } + + allSecrets, err := p.Get(opts) + if err != nil { + return err + } + + // Build a map of all secrets for key filtering + allSecretsMap := make(map[string]string) + for _, secret := range allSecrets { + if secret.Value != "" { + allSecretsMap[secret.Key] = secret.Value + } + } + + var secretsList []util.KeyValue + if len(filterKeys) > 0 { + // Check for missing keys + var missingKeys []string + for _, key := range filterKeys { + if _, ok := allSecretsMap[key]; !ok { + missingKeys = append(missingKeys, key) + } + } + if len(missingKeys) > 0 { + return fmt.Errorf("🄔 failed to export — the following secret(s) do not exist: %s", strings.Join(missingKeys, ", ")) + } + // Export only the requested keys (in the order they were specified) + for _, key := range filterKeys { + secretsList = append(secretsList, util.KeyValue{Key: key, Value: allSecretsMap[key]}) + } + } else { + for _, secret := range allSecrets { + if secret.Value == "" { + continue + } + secretsList = append(secretsList, util.KeyValue{Key: secret.Key, Value: secret.Value}) + } + } + + switch format { + case "json": + util.ExportJSON(secretsList) + case "csv": + util.ExportCSV(secretsList) + case "yaml": + util.ExportYAML(secretsList) + case "xml": + util.ExportXML(secretsList) + case "toml": + util.ExportTOML(secretsList) + case "hcl": + util.ExportHCL(secretsList) + case "ini": + util.ExportINI(secretsList) + case "java_properties": + util.ExportJavaProperties(secretsList) + case "kv": + util.ExportKV(secretsList) + case "dotenv": + util.ExportDotenv(secretsList) + default: + fmt.Fprintf(os.Stderr, "Unknown format: %s, using dotenv\n", format) + util.ExportDotenv(secretsList) + } + + return nil +} diff --git a/src/cmd/secrets_get.go b/src/cmd/secrets_get.go new file mode 100644 index 00000000..bf6147f0 --- /dev/null +++ b/src/cmd/secrets_get.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/spf13/cobra" +) + +var secretsGetCmd = &cobra.Command{ + Use: "get ", + Short: "šŸ” Fetch details about a secret in JSON", + Args: cobra.ExactArgs(1), + RunE: runSecretsGet, +} + +func init() { + secretsGetCmd.Flags().String("env", "", "Environment name") + secretsGetCmd.Flags().String("app", "", "Application name") + secretsGetCmd.Flags().String("app-id", "", "Application ID") + secretsGetCmd.Flags().String("path", "/", "Path filter") + secretsGetCmd.Flags().String("tags", "", "Filter by tags") + secretsGetCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") + secretsGetCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + secretsCmd.AddCommand(secretsGetCmd) +} + +func runSecretsGet(cmd *cobra.Command, args []string) error { + key := strings.ToUpper(args[0]) + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + tags, _ := cmd.Flags().GetString("tags") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + + appName, envName, appID = phase.GetConfig(appName, envName, appID) + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + opts := sdk.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Keys: []string{key}, + Tag: tags, + Path: path, + Dynamic: true, + Lease: util.ParseBoolFlag(generateLeases), + } + if cmd.Flags().Changed("lease-ttl") { + opts.LeaseTTL = &leaseTTL + } + + secrets, err := p.Get(opts) + if err != nil { + return err + } + + var found *sdk.SecretResult + for i, s := range secrets { + if s.Key == key { + found = &secrets[i] + break + } + } + + if found == nil { + return fmt.Errorf("šŸ” Secret not found") + } + + data, _ := json.MarshalIndent(found, "", " ") + fmt.Println(string(data)) + return nil +} diff --git a/src/cmd/secrets_import.go b/src/cmd/secrets_import.go new file mode 100644 index 00000000..79756b49 --- /dev/null +++ b/src/cmd/secrets_import.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "fmt" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/spf13/cobra" +) + +var secretsImportCmd = &cobra.Command{ + Use: "import ", + Short: "šŸ“© Import secrets from a .env file", + Args: cobra.ExactArgs(1), + RunE: runSecretsImport, +} + +func init() { + secretsImportCmd.Flags().String("env", "", "Environment name") + secretsImportCmd.Flags().String("app", "", "Application name") + secretsImportCmd.Flags().String("app-id", "", "Application ID") + secretsImportCmd.Flags().String("path", "/", "Path for imported secrets") + secretsCmd.AddCommand(secretsImportCmd) +} + +func runSecretsImport(cmd *cobra.Command, args []string) error { + envFile := args[0] + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + + // Parse env file + pairs, err := util.ParseEnvFile(envFile) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", envFile, err) + } + + appName, envName, appID = phase.GetConfig(appName, envName, appID) + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + err = p.Create(sdk.CreateOptions{ + KeyValuePairs: pairs, + EnvName: envName, + AppName: appName, + AppID: appID, + Path: path, + }) + if err != nil { + return fmt.Errorf("failed to import secrets: %w", err) + } + + fmt.Println(util.BoldGreen(fmt.Sprintf("āœ… Successfully imported and encrypted %d secrets.", len(pairs)))) + if envName == "" { + fmt.Println("To view them please run: phase secrets list") + } else { + fmt.Printf("To view them please run: phase secrets list --env %s\n", envName) + } + return nil +} diff --git a/src/cmd/secrets_list.go b/src/cmd/secrets_list.go new file mode 100644 index 00000000..81720f28 --- /dev/null +++ b/src/cmd/secrets_list.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "fmt" + + "github.com/phasehq/cli/pkg/display" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/spf13/cobra" +) + +var secretsListCmd = &cobra.Command{ + Use: "list", + Short: "šŸ“‡ List all the secrets", + Long: `šŸ“‡ List all the secrets + +Icon legend: + šŸ”— Secret references another secret in the same environment + 🌐 Cross-environment reference (secret from another environment in the same or different application) + šŸ”– Tag associated with the secret + šŸ’¬ Comment associated with the secret + šŸ” Personal secret override (visible only to you) + āš”ļø Dynamic secret`, + RunE: runSecretsList, +} + +func init() { + secretsListCmd.Flags().Bool("show", false, "Show decrypted secret values") + secretsListCmd.Flags().String("env", "", "Environment name") + secretsListCmd.Flags().String("app", "", "Application name") + secretsListCmd.Flags().String("app-id", "", "Application ID") + secretsListCmd.Flags().String("tags", "", "Filter by tags") + secretsListCmd.Flags().String("path", "", "Path filter") + secretsListCmd.Flags().String("generate-leases", "", "Generate leases for dynamic secrets (defaults to value of --show)") + secretsListCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + secretsCmd.AddCommand(secretsListCmd) +} + +// listSecrets fetches and displays secrets. Used by list, create, update, and delete commands. +func listSecrets(p *sdk.Phase, envName, appName, appID, tags, path string, show, dynamic, lease bool, leaseTTL *int) error { + opts := sdk.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Tag: tags, + Path: path, + Dynamic: dynamic, + Lease: lease, + LeaseTTL: leaseTTL, + } + + spinner := util.NewSpinner("Fetching secrets...") + spinner.Start() + secrets, err := p.Get(opts) + spinner.Stop() + if err != nil { + return err + } + + display.RenderSecretsTree(secrets, show) + return nil +} + +func runSecretsList(cmd *cobra.Command, args []string) error { + show, _ := cmd.Flags().GetBool("show") + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + tags, _ := cmd.Flags().GetString("tags") + path, _ := cmd.Flags().GetString("path") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + + appName, envName, appID = phase.GetConfig(appName, envName, appID) + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + // Match Python behavior: lease=show unless --generate-leases explicitly set + var lease bool + if cmd.Flags().Changed("generate-leases") { + lease = util.ParseBoolFlag(generateLeases) + } else { + lease = show + } + var leaseTTLPtr *int + if cmd.Flags().Changed("lease-ttl") { + leaseTTLPtr = &leaseTTL + } + + if err := listSecrets(p, envName, appName, appID, tags, path, show, true, lease, leaseTTLPtr); err != nil { + return err + } + + fmt.Println("šŸ”¬ To view a secret, use: phase secrets get ") + if !show { + fmt.Println("🄽 To uncover the secrets, use: phase secrets list --show") + } + return nil +} diff --git a/src/cmd/secrets_update.go b/src/cmd/secrets_update.go new file mode 100644 index 00000000..7aa0526b --- /dev/null +++ b/src/cmd/secrets_update.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "syscall" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/phasehq/golang-sdk/phase/misc" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var secretsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "šŸ“ Update an existing secret", + Args: cobra.MaximumNArgs(1), + RunE: runSecretsUpdate, +} + +func init() { + secretsUpdateCmd.Flags().String("env", "", "Environment name") + secretsUpdateCmd.Flags().String("app", "", "Application name") + secretsUpdateCmd.Flags().String("app-id", "", "Application ID") + secretsUpdateCmd.Flags().String("path", "/", "Source path of the secret") + secretsUpdateCmd.Flags().String("updated-path", "", "New path for the secret") + secretsUpdateCmd.Flags().Bool("override", false, "Update override value") + secretsUpdateCmd.Flags().Bool("toggle-override", false, "Toggle override state") + secretsUpdateCmd.Flags().String("random", "", "Random type (hex, alphanumeric, base64, base64url, key128, key256)") + secretsUpdateCmd.Flags().Int("length", 32, "Length for random secret") + secretsCmd.AddCommand(secretsUpdateCmd) +} + +func runSecretsUpdate(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + sourcePath, _ := cmd.Flags().GetString("path") + destPath, _ := cmd.Flags().GetString("updated-path") + override, _ := cmd.Flags().GetBool("override") + toggleOverride, _ := cmd.Flags().GetBool("toggle-override") + randomType, _ := cmd.Flags().GetString("random") + randomLength, _ := cmd.Flags().GetInt("length") + + var key string + if len(args) > 0 { + key = args[0] + } else { + fmt.Print("šŸ—ļø\u200A Please enter the key: ") + fmt.Scanln(&key) + } + + key = strings.ReplaceAll(key, " ", "_") + key = strings.ToUpper(key) + + var newValue string + if toggleOverride { + // No value needed for toggle + } else if randomType != "" { + validTypes := map[string]bool{"hex": true, "alphanumeric": true, "base64": true, "base64url": true, "key128": true, "key256": true} + if !validTypes[randomType] { + return fmt.Errorf("unsupported random type: %s. Supported types: hex, alphanumeric, base64, base64url, key128, key256", randomType) + } + if (randomType == "key128" || randomType == "key256") && randomLength != 32 { + fmt.Fprintf(os.Stderr, "āš ļø\u200A Warning: The length argument is ignored for '%s'. Using default lengths.\n", randomType) + } + var err error + newValue, err = misc.GenerateRandomSecret(randomType, randomLength) + if err != nil { + return fmt.Errorf("failed to generate random secret: %w", err) + } + } else if override { + fmt.Print("✨ Please enter the šŸ” override value (hidden): ") + valueBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read value: %w", err) + } + fmt.Println() + newValue = string(valueBytes) + } else { + if term.IsTerminal(int(syscall.Stdin)) { + fmt.Printf("✨ Please enter the new value for %s (hidden): ", key) + valueBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read value: %w", err) + } + fmt.Println() + newValue = string(valueBytes) + } else { + buf := make([]byte, 1024*1024) + n, _ := os.Stdin.Read(buf) + newValue = strings.TrimSpace(string(buf[:n])) + } + } + + appName, envName, appID = phase.GetConfig(appName, envName, appID) + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + result, err := p.Update(sdk.UpdateOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Key: key, + Value: newValue, + SourcePath: sourcePath, + DestinationPath: destPath, + Override: override, + ToggleOverride: toggleOverride, + }) + if err != nil { + return fmt.Errorf("error updating secret: %w", err) + } + + if result == "Success" { + fmt.Println(util.BoldGreen("āœ… Successfully updated the secret.")) + listPath := sourcePath + if destPath != "" { + listPath = destPath + } + if err := listSecrets(p, envName, appName, appID, "", listPath, false, false, false, nil); err != nil { + return err + } + } else { + fmt.Println(result) + } + return nil +} diff --git a/src/cmd/shell.go b/src/cmd/shell.go new file mode 100644 index 00000000..c0b11bdb --- /dev/null +++ b/src/cmd/shell.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/spf13/cobra" +) + +var shellCmd = &cobra.Command{ + Use: "shell", + Short: "🐚 Launch a sub-shell with secrets as environment variables", + RunE: runShell, +} + +func init() { + shellCmd.Flags().String("env", "", "Environment name") + shellCmd.Flags().String("app", "", "Application name") + shellCmd.Flags().String("app-id", "", "Application ID") + shellCmd.Flags().String("tags", "", "Filter by tags") + shellCmd.Flags().String("path", "/", "Path filter") + shellCmd.Flags().String("shell", "", "Shell to use (bash, zsh, fish, sh, powershell, pwsh, cmd)") + shellCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") + shellCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + rootCmd.AddCommand(shellCmd) +} + +func runShell(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + tags, _ := cmd.Flags().GetString("tags") + path, _ := cmd.Flags().GetString("path") + shellType, _ := cmd.Flags().GetString("shell") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + + appName, envName, appID = phase.GetConfig(appName, envName, appID) + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + opts := sdk.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Tag: tags, + Path: path, + Dynamic: true, + Lease: util.ParseBoolFlag(generateLeases), + } + if cmd.Flags().Changed("lease-ttl") { + opts.LeaseTTL = &leaseTTL + } + + spinner := util.NewSpinner("Fetching secrets...") + spinner.Start() + allSecrets, err := p.Get(opts) + spinner.Stop() + if err != nil { + return err + } + + resolvedSecrets := map[string]string{} + for _, secret := range allSecrets { + if secret.Value == "" { + continue + } + resolvedSecrets[secret.Key] = secret.Value + } + + // Collect env/app info for display + apps := map[string]bool{} + envs := map[string]bool{} + for _, s := range allSecrets { + if _, ok := resolvedSecrets[s.Key]; ok { + if s.Application != "" { + apps[s.Application] = true + } + envs[s.Environment] = true + } + } + appNames := mapKeys(apps) + envNames := mapKeys(envs) + + // Build environment: inherit current env, add secrets and shell markers + envSlice := os.Environ() + for k, v := range resolvedSecrets { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + envSlice = append(envSlice, "PHASE_SHELL=true") + if len(envNames) > 0 { + envSlice = append(envSlice, fmt.Sprintf("PHASE_ENV=%s", envNames[0])) + } + if len(appNames) > 0 { + envSlice = append(envSlice, fmt.Sprintf("PHASE_APP=%s", appNames[0])) + } + if os.Getenv("TERM") == "" { + envSlice = append(envSlice, "TERM=xterm-256color") + } + + // Determine shell + var shellArgs []string + if shellType != "" { + shellArgs, err = util.GetShellCommand(shellType) + if err != nil { + return err + } + } else { + shellArgs = util.GetDefaultShell() + if shellArgs == nil { + return fmt.Errorf("no shell found") + } + } + + secretCount := len(resolvedSecrets) + shellName := shellArgs[0] + if path != "" && path != "/" { + fmt.Fprintf(os.Stderr, "🐚 Initialized %s with %s secrets from Application: %s, Environment: %s, Path: %s\n", + util.BoldGreenErr(shellName), + util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)), + util.BoldCyanErr(strings.Join(appNames, ", ")), + util.BoldGreenErr(strings.Join(envNames, ", ")), + util.BoldYellowErr(path)) + } else { + fmt.Fprintf(os.Stderr, "🐚 Initialized %s with %s secrets from Application: %s, Environment: %s\n", + util.BoldGreenErr(shellName), + util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)), + util.BoldCyanErr(strings.Join(appNames, ", ")), + util.BoldGreenErr(strings.Join(envNames, ", "))) + } + fmt.Fprintf(os.Stderr, "%s Secrets are only available in this session. Type %s or press %s to exit.\n", + util.BoldYellowErr("Remember:"), + util.BoldErr("exit"), + util.BoldErr("Ctrl+D")) + + // Launch shell + c := exec.Command(shellArgs[0], shellArgs[1:]...) + c.Env = envSlice + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + + if err := c.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + fmt.Fprintf(os.Stderr, "%s Phase secrets are no longer available.\n", util.BoldRedErr("🐚 Shell session ended.")) + os.Exit(exitErr.ExitCode()) + } + return err + } + fmt.Fprintf(os.Stderr, "%s Phase secrets are no longer available.\n", util.BoldRedErr("🐚 Shell session ended.")) + return nil +} diff --git a/src/cmd/update.go b/src/cmd/update.go new file mode 100644 index 00000000..a0492c01 --- /dev/null +++ b/src/cmd/update.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +func init() { + if runtime.GOOS == "linux" { + updateCmd := &cobra.Command{ + Use: "update", + Short: "šŸ†™ Update the Phase CLI to the latest version", + RunE: runUpdate, + } + rootCmd.AddCommand(updateCmd) + } +} + +func runUpdate(cmd *cobra.Command, args []string) error { + fmt.Println("Updating Phase CLI...") + + resp, err := http.Get("https://pkg.phase.dev/install.sh") + if err != nil { + return fmt.Errorf("failed to download install script: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download install script: HTTP %d", resp.StatusCode) + } + + tmpFile, err := os.CreateTemp("", "phase-install-*.sh") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to write install script: %w", err) + } + tmpFile.Close() + + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to make script executable: %w", err) + } + + c := exec.Command(tmpPath) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + + if err := c.Run(); err != nil { + return fmt.Errorf("update failed: %w", err) + } + + fmt.Println(util.BoldGreen("āœ… Update completed successfully.")) + return nil +} diff --git a/src/cmd/users.go b/src/cmd/users.go new file mode 100644 index 00000000..ec243cd4 --- /dev/null +++ b/src/cmd/users.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var usersCmd = &cobra.Command{ + Use: "users", + Short: "šŸ‘„ Manage users and accounts", +} + +func init() { + rootCmd.AddCommand(usersCmd) +} diff --git a/src/cmd/users_keyring.go b/src/cmd/users_keyring.go new file mode 100644 index 00000000..07001d7a --- /dev/null +++ b/src/cmd/users_keyring.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" +) + +var usersKeyringCmd = &cobra.Command{ + Use: "keyring", + Short: "šŸ” Display information about the Phase keyring", + RunE: runUsersKeyring, +} + +func init() { + usersCmd.AddCommand(usersKeyringCmd) +} + +func runUsersKeyring(cmd *cobra.Command, args []string) error { + switch runtime.GOOS { + case "darwin": + fmt.Println("Keyring backend: macOS Keychain") + case "linux": + fmt.Println("Keyring backend: GNOME Keyring / Secret Service") + case "windows": + fmt.Println("Keyring backend: Windows Credential Manager") + default: + fmt.Printf("Keyring backend: Unknown (%s)\n", runtime.GOOS) + } + return nil +} diff --git a/src/cmd/users_logout.go b/src/cmd/users_logout.go new file mode 100644 index 00000000..98398439 --- /dev/null +++ b/src/cmd/users_logout.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/spf13/cobra" +) + +var usersLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "šŸƒ Logout from phase-cli", + RunE: runUsersLogout, +} + +func init() { + usersLogoutCmd.Flags().Bool("purge", false, "Purge all local data") + usersCmd.AddCommand(usersLogoutCmd) +} + +func runUsersLogout(cmd *cobra.Command, args []string) error { + purge, _ := cmd.Flags().GetBool("purge") + + if purge { + // Delete all keyring entries and remove local data + ids, err := config.GetDefaultAccountID(true) + if err != nil { + return err + } + for _, id := range ids { + keyring.DeleteCredentials(id) + } + if _, err := os.Stat(config.PhaseSecretsDir); err == nil { + if err := os.RemoveAll(config.PhaseSecretsDir); err != nil { + return fmt.Errorf("failed to purge local data: %w", err) + } + fmt.Println("Logged out and purged all local data.") + } else { + fmt.Println("No local data found to purge.") + } + } else { + // Remove current user + ids, err := config.GetDefaultAccountID(false) + if err != nil { + return fmt.Errorf("no configuration found. Please run 'phase auth' to set up your configuration") + } + if len(ids) == 0 || ids[0] == "" { + return fmt.Errorf("no default user in configuration found") + } + + accountID := ids[0] + keyring.DeleteCredentials(accountID) + + if err := config.RemoveUser(accountID); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + fmt.Println("Logged out successfully.") + } + + return nil +} diff --git a/src/cmd/users_switch.go b/src/cmd/users_switch.go new file mode 100644 index 00000000..204e3ee8 --- /dev/null +++ b/src/cmd/users_switch.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + + "github.com/manifoldco/promptui" + "github.com/phasehq/cli/pkg/config" + "github.com/spf13/cobra" +) + +var usersSwitchCmd = &cobra.Command{ + Use: "switch", + Short: "šŸŖ„\u200A Switch between Phase users, orgs and hosts", + RunE: runUsersSwitch, +} + +func init() { + usersCmd.AddCommand(usersSwitchCmd) +} + +func runUsersSwitch(cmd *cobra.Command, args []string) error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if len(cfg.PhaseUsers) == 0 { + return fmt.Errorf("no users found. Please authenticate first with 'phase auth'") + } + + // Build display labels for each user + items := make([]string, len(cfg.PhaseUsers)) + for i, user := range cfg.PhaseUsers { + orgName := "N/A" + if user.OrganizationName != nil { + orgName = *user.OrganizationName + } + email := "Service Account" + if user.Email != "" { + email = user.Email + } + shortID := user.ID + if len(shortID) > 8 { + shortID = shortID[:8] + } + marker := "" + if user.ID == cfg.DefaultUser { + marker = " (current)" + } + items[i] = fmt.Sprintf("šŸ¢ %s, āœ‰ļø %s, ā˜ļø %s, šŸ†” %s%s", orgName, email, user.Host, shortID, marker) + } + + prompt := promptui.Select{ + Label: "Select a user", + Items: items, + } + idx, _, err := prompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + + selectedUser := cfg.PhaseUsers[idx] + if err := config.SetDefaultUser(selectedUser.ID); err != nil { + return fmt.Errorf("failed to switch user: %w", err) + } + + orgName := "N/A" + if selectedUser.OrganizationName != nil { + orgName = *selectedUser.OrganizationName + } + email := "Service Account" + if selectedUser.Email != "" { + email = selectedUser.Email + } + + fmt.Printf("Switched to account šŸ™‹: %s (%s)\n", email, orgName) + return nil +} diff --git a/src/cmd/users_whoami.go b/src/cmd/users_whoami.go new file mode 100644 index 00000000..b6e23270 --- /dev/null +++ b/src/cmd/users_whoami.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + + "github.com/phasehq/cli/pkg/config" + "github.com/spf13/cobra" +) + +var usersWhoamiCmd = &cobra.Command{ + Use: "whoami", + Short: "šŸ™‹ See details of the current user", + RunE: runUsersWhoami, +} + +func init() { + usersCmd.AddCommand(usersWhoamiCmd) +} + +func runUsersWhoami(cmd *cobra.Command, args []string) error { + user, err := config.GetDefaultUser() + if err != nil { + return fmt.Errorf("not logged in: %w", err) + } + + email := user.Email + if email == "" { + email = "N/A (Service Account)" + } + + fmt.Printf("āœ‰ļø\u200A Email: %s\n", email) + fmt.Printf("šŸ™‹ Account ID: %s\n", user.ID) + orgName := "N/A" + if user.OrganizationName != nil { + orgName = *user.OrganizationName + } + fmt.Printf("šŸ¢ Organization: %s\n", orgName) + fmt.Printf("ā˜ļø\u200A Host: %s\n", user.Host) + return nil +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 00000000..bccf8c2c --- /dev/null +++ b/src/go.mod @@ -0,0 +1,39 @@ +module github.com/phasehq/cli + +go 1.24.0 + +require ( + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/manifoldco/promptui v0.9.0 + github.com/phasehq/golang-sdk v0.0.0-00010101000000-000000000000 + github.com/spf13/cobra v1.8.0 + github.com/zalando/go-keyring v0.2.6 + golang.org/x/term v0.39.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/smithy-go v1.24.0 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sys v0.40.0 // indirect +) + +replace github.com/phasehq/golang-sdk => /Users/nimish/git/phase/golang-sdk diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 00000000..cf11f28a --- /dev/null +++ b/src/go.sum @@ -0,0 +1,73 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/main.go b/src/main.go new file mode 100644 index 00000000..801e9bfe --- /dev/null +++ b/src/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/phasehq/cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go new file mode 100644 index 00000000..dd9fc7aa --- /dev/null +++ b/src/pkg/config/config.go @@ -0,0 +1,207 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + PhaseCloudAPIHost = "https://console.phase.dev" + PhaseEnvConfig = ".phase.json" +) + +var ( + PhaseSecretsDir = filepath.Join(homeDir(), ".phase", "secrets") + ConfigFilePath = filepath.Join(PhaseSecretsDir, "config.json") +) + +func homeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + +type UserConfig struct { + Host string `json:"host"` + ID string `json:"id"` + Email string `json:"email,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` + OrganizationName *string `json:"organization_name,omitempty"` + WrappedKeyShare *string `json:"wrapped_key_share,omitempty"` + Token string `json:"token,omitempty"` +} + +type Config struct { + DefaultUser string `json:"default-user"` + PhaseUsers []UserConfig `json:"phase-users"` +} + +func LoadConfig() (*Config, error) { + data, err := os.ReadFile(ConfigFilePath) + if err != nil { + return nil, err + } + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + return &config, nil +} + +func SaveConfig(config *Config) error { + if err := os.MkdirAll(PhaseSecretsDir, 0700); err != nil { + return err + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return os.WriteFile(ConfigFilePath, data, 0600) +} + +func GetDefaultUser() (*UserConfig, error) { + config, err := LoadConfig() + if err != nil { + return nil, fmt.Errorf("please login with phase auth or supply a PHASE_SERVICE_TOKEN as an environment variable") + } + if config.DefaultUser == "" { + return nil, fmt.Errorf("no default user set") + } + for _, user := range config.PhaseUsers { + if user.ID == config.DefaultUser { + return &user, nil + } + } + return nil, fmt.Errorf("no user found in config.json with id: %s", config.DefaultUser) +} + +func GetDefaultAccountID(allIDs bool) ([]string, error) { + config, err := LoadConfig() + if err != nil { + return nil, fmt.Errorf("please login with phase auth or supply a PHASE_SERVICE_TOKEN as an environment variable") + } + if allIDs { + var ids []string + for _, user := range config.PhaseUsers { + ids = append(ids, user.ID) + } + return ids, nil + } + return []string{config.DefaultUser}, nil +} + +func GetDefaultUserHost() (string, error) { + if token := os.Getenv("PHASE_SERVICE_TOKEN"); token != "" { + host := os.Getenv("PHASE_HOST") + if host == "" { + host = PhaseCloudAPIHost + } + return host, nil + } + + config, err := LoadConfig() + if err != nil { + return "", fmt.Errorf("config file not found and no PHASE_SERVICE_TOKEN environment variable set") + } + + for _, user := range config.PhaseUsers { + if user.ID == config.DefaultUser { + return user.Host, nil + } + } + return "", fmt.Errorf("no user found in config.json with id: %s", config.DefaultUser) +} + +func GetDefaultUserToken() (string, error) { + config, err := LoadConfig() + if err != nil { + return "", fmt.Errorf("config file not found. Please login with phase auth or supply a PHASE_SERVICE_TOKEN as an environment variable") + } + if config.DefaultUser == "" { + return "", fmt.Errorf("default user ID is missing in the config file") + } + for _, user := range config.PhaseUsers { + if user.ID == config.DefaultUser { + if user.Token == "" { + return "", fmt.Errorf("token for the default user (ID: %s) is not found in the config file", config.DefaultUser) + } + return user.Token, nil + } + } + return "", fmt.Errorf("default user not found in the config file") +} + +func AddUser(user UserConfig) error { + config, err := LoadConfig() + if err != nil { + config = &Config{PhaseUsers: []UserConfig{}} + } + // Replace existing user with same ID or add new + found := false + for i, u := range config.PhaseUsers { + if u.ID == user.ID { + config.PhaseUsers[i] = user + found = true + break + } + } + if !found { + config.PhaseUsers = append(config.PhaseUsers, user) + } + config.DefaultUser = user.ID + return SaveConfig(config) +} + +func GetDefaultUserOrg() (string, error) { + user, err := GetDefaultUser() + if err != nil { + return "", err + } + if user.OrganizationName != nil && *user.OrganizationName != "" { + return *user.OrganizationName, nil + } + return "", fmt.Errorf("no organization name found for default user") +} + +func SetDefaultUser(accountID string) error { + config, err := LoadConfig() + if err != nil { + return err + } + found := false + for _, u := range config.PhaseUsers { + if u.ID == accountID { + found = true + break + } + } + if !found { + return fmt.Errorf("no user found with ID: %s", accountID) + } + config.DefaultUser = accountID + return SaveConfig(config) +} + +func RemoveUser(id string) error { + config, err := LoadConfig() + if err != nil { + return err + } + var remaining []UserConfig + for _, u := range config.PhaseUsers { + if u.ID != id { + remaining = append(remaining, u) + } + } + config.PhaseUsers = remaining + if len(remaining) == 0 { + config.DefaultUser = "" + } else if config.DefaultUser == id { + config.DefaultUser = remaining[0].ID + } + return SaveConfig(config) +} diff --git a/src/pkg/config/phase_json.go b/src/pkg/config/phase_json.go new file mode 100644 index 00000000..8d11b66d --- /dev/null +++ b/src/pkg/config/phase_json.go @@ -0,0 +1,64 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" +) + +type PhaseJSONConfig struct { + Version string `json:"version"` + PhaseApp string `json:"phaseApp"` + AppID string `json:"appId"` + DefaultEnv string `json:"defaultEnv"` + EnvID string `json:"envId"` + MonorepoSupport bool `json:"monorepoSupport"` +} + +func FindPhaseConfig(maxDepth int) *PhaseJSONConfig { + // Check env var override for search depth + if envDepth := os.Getenv("PHASE_CONFIG_PARENT_DIR_SEARCH_DEPTH"); envDepth != "" { + if d, err := strconv.Atoi(envDepth); err == nil { + maxDepth = d + } + } + + currentDir, err := os.Getwd() + if err != nil { + return nil + } + originalDir := currentDir + + for i := 0; i <= maxDepth; i++ { + configPath := filepath.Join(currentDir, PhaseEnvConfig) + data, err := os.ReadFile(configPath) + if err == nil { + var config PhaseJSONConfig + if err := json.Unmarshal(data, &config); err == nil { + // Only use config from parent dirs if monorepoSupport is true + if currentDir == originalDir || config.MonorepoSupport { + return &config + } + } + } + + parentDir := filepath.Dir(currentDir) + if parentDir == currentDir { + break + } + currentDir = parentDir + } + return nil +} + +func WritePhaseConfig(config *PhaseJSONConfig) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(PhaseEnvConfig, data, 0600); err != nil { + return err + } + return nil +} diff --git a/src/pkg/config/phase_json_test.go b/src/pkg/config/phase_json_test.go new file mode 100644 index 00000000..b602e2c5 --- /dev/null +++ b/src/pkg/config/phase_json_test.go @@ -0,0 +1,132 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func writePhaseConfigFile(t *testing.T, dir string, monorepoSupport bool) { + t.Helper() + cfg := PhaseJSONConfig{ + Version: "2", + PhaseApp: "TestApp", + AppID: "00000000-0000-0000-0000-000000000000", + DefaultEnv: "Development", + EnvID: "00000000-0000-0000-0000-000000000001", + MonorepoSupport: monorepoSupport, + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, PhaseEnvConfig), data, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } +} + +func withWorkingDir(t *testing.T, dir string) { + t.Helper() + original, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir to %s: %v", dir, err) + } + t.Cleanup(func() { + _ = os.Chdir(original) + }) +} + +func TestFindPhaseConfig_InCurrentDir(t *testing.T) { + base := t.TempDir() + writePhaseConfigFile(t, base, false) + withWorkingDir(t, base) + + cfg := FindPhaseConfig(8) + if cfg == nil { + t.Fatal("expected config, got nil") + } + if cfg.PhaseApp != "TestApp" { + t.Fatalf("unexpected phase app: %s", cfg.PhaseApp) + } +} + +func TestFindPhaseConfig_InParentWithMonorepoSupport(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "parent") + grandchild := filepath.Join(parent, "child", "grandchild") + if err := os.MkdirAll(grandchild, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + writePhaseConfigFile(t, parent, true) + withWorkingDir(t, grandchild) + + cfg := FindPhaseConfig(8) + if cfg == nil { + t.Fatal("expected parent config, got nil") + } + if cfg.AppID != "00000000-0000-0000-0000-000000000000" { + t.Fatalf("unexpected app id: %s", cfg.AppID) + } +} + +func TestFindPhaseConfig_InParentWithoutMonorepoSupport(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "parent") + grandchild := filepath.Join(parent, "child", "grandchild") + if err := os.MkdirAll(grandchild, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + writePhaseConfigFile(t, parent, false) + withWorkingDir(t, grandchild) + + cfg := FindPhaseConfig(8) + if cfg != nil { + t.Fatalf("expected nil config, got %+v", cfg) + } +} + +func TestFindPhaseConfig_RespectsMaxDepthAndEnvOverride(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "parent") + grandchild := filepath.Join(parent, "child", "grandchild") + if err := os.MkdirAll(grandchild, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + writePhaseConfigFile(t, parent, true) + withWorkingDir(t, grandchild) + + if cfg := FindPhaseConfig(1); cfg != nil { + t.Fatalf("expected nil with maxDepth=1, got %+v", cfg) + } + if cfg := FindPhaseConfig(2); cfg == nil { + t.Fatal("expected config with maxDepth=2") + } + + t.Setenv("PHASE_CONFIG_PARENT_DIR_SEARCH_DEPTH", "1") + if cfg := FindPhaseConfig(8); cfg != nil { + t.Fatalf("expected nil with env override depth=1, got %+v", cfg) + } +} + +func TestFindPhaseConfig_InvalidJSONAndNoConfig(t *testing.T) { + base := t.TempDir() + withWorkingDir(t, base) + + if cfg := FindPhaseConfig(8); cfg != nil { + t.Fatalf("expected nil with no config, got %+v", cfg) + } + + if err := os.WriteFile(filepath.Join(base, PhaseEnvConfig), []byte("{invalid"), 0o600); err != nil { + t.Fatalf("write invalid config: %v", err) + } + if cfg := FindPhaseConfig(8); cfg != nil { + t.Fatalf("expected nil with invalid json, got %+v", cfg) + } +} diff --git a/src/pkg/display/tree.go b/src/pkg/display/tree.go new file mode 100644 index 00000000..90003530 --- /dev/null +++ b/src/pkg/display/tree.go @@ -0,0 +1,308 @@ +package display + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "golang.org/x/term" +) + +var ( + // Simplified patterns for display icons only (Go doesn't support lookaheads) + crossEnvPattern = regexp.MustCompile(`\$\{[^}]*\.[^}]+\}`) + localRefPattern = regexp.MustCompile(`\$\{[^}.]+\}`) +) + +func getTerminalWidth() int { + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { + return w + } + return 80 +} + +// runeWidth returns the terminal column width of a single rune. +// Only emoji with East Asian Width "W" (Wide) are counted as 2 columns. +// Ambiguous-width characters (EAW=A/N) that need VS16 for emoji presentation +// are avoided in our display strings; we use only EAW=W emoji for indicators. +func runeWidth(r rune) int { + switch { + case r == '\uFE0F' || r == '\u200A' || r == '\u200B' || r == '\u200D': + return 0 // variation selectors, hair space, zero-width space, ZWJ + case r >= 0x1F000: + return 2 // Supplementary emoji (nearly all EAW=W) + case r >= 0x2600 && r <= 0x27BF: + return 2 // Misc Symbols & Dingbats (⚔ etc.) + default: + return 1 + } +} + +// displayWidth returns the visual terminal column width of s. +func displayWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// padRight pads s with spaces to fill exactly width display columns. +func padRight(s string, width int) string { + sw := displayWidth(s) + if sw >= width { + return s + } + return s + strings.Repeat(" ", width-sw) +} + +// truncateToWidth truncates s to fit within maxWidth display columns. +func truncateToWidth(s string, maxWidth int) string { + if displayWidth(s) <= maxWidth { + return s + } + var result []byte + w := 0 + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + rw := runeWidth(r) + if w+rw > maxWidth-1 { + break + } + result = append(result, s[i:i+size]...) + w += rw + i += size + } + return string(result) + "…" +} + +// wrapToWidth splits s into lines that each fit within maxWidth display columns. +func wrapToWidth(s string, maxWidth int) []string { + if maxWidth <= 0 || displayWidth(s) <= maxWidth { + return []string{s} + } + var lines []string + var line []byte + w := 0 + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + rw := runeWidth(r) + if rw > 0 && w+rw > maxWidth { + lines = append(lines, string(line)) + line = nil + w = 0 + } + line = append(line, s[i:i+size]...) + w += rw + i += size + } + if len(line) > 0 { + lines = append(lines, string(line)) + } + return lines +} + +func censorSecret(secret string, maxLength int) string { + if len(secret) <= 6 { + return strings.Repeat("*", len(secret)) + } + censored := secret[:3] + strings.Repeat("*", len(secret)-6) + secret[len(secret)-3:] + if len(censored) > maxLength && maxLength > 6 { + return censored[:maxLength-6] + } + return censored +} + +// renderSecretRow renders a single secret row. +func renderSecretRow(pathPrefix string, s sdk.SecretResult, show bool, keyWidth, valueWidth int) { + keyDisplay := s.Key + if len(s.Tags) > 0 { + keyDisplay += " šŸ”–" + } + if s.Comment != "" { + keyDisplay += " šŸ’¬" + } + + icon := "" + if crossEnvPattern.MatchString(s.Value) { + icon += "🌐 " + } + if localRefPattern.MatchString(s.Value) { + icon += "šŸ”— " + } + + personalIndicator := "" + if s.Overridden { + personalIndicator = "šŸ” " + } + + var valueDisplay string + if s.IsDynamic && !show { + valueDisplay = "****************" + } else if show { + valueDisplay = s.Value + } else { + censorLen := valueWidth - displayWidth(icon) - displayWidth(personalIndicator) - 2 + if censorLen < 6 { + censorLen = 6 + } + valueDisplay = censorSecret(s.Value, censorLen) + } + valueDisplay = icon + personalIndicator + valueDisplay + + // Truncate key (never wraps) + keyDisplay = truncateToWidth(keyDisplay, keyWidth) + + if !show { + valueDisplay = truncateToWidth(valueDisplay, valueWidth) + } + + if show { + // Wrap long values within the value column + valueLines := wrapToWidth(valueDisplay, valueWidth) + for i, vline := range valueLines { + if i == 0 { + fmt.Fprintf(os.Stdout, " %s │ %s│ %s│\n", + pathPrefix, padRight(keyDisplay, keyWidth), padRight(vline, valueWidth)) + } else { + fmt.Fprintf(os.Stdout, " %s │ %s│ %s│\n", + pathPrefix, strings.Repeat(" ", keyWidth), padRight(vline, valueWidth)) + } + } + } else { + valueDisplay = truncateToWidth(valueDisplay, valueWidth) + fmt.Fprintf(os.Stdout, " %s │ %s│ %s│\n", + pathPrefix, padRight(keyDisplay, keyWidth), padRight(valueDisplay, valueWidth)) + } +} + +// RenderSecretsTree renders secrets in a tree view with path hierarchy +func RenderSecretsTree(secrets []sdk.SecretResult, show bool) { + if len(secrets) == 0 { + fmt.Println("No secrets to display.") + return + } + + appName := secrets[0].Application + envName := secrets[0].Environment + + bold, cyan, green, magenta, reset := util.AnsiCodes() + + fmt.Printf(" %s Secrets for Application: %s%s%s%s, Environment: %s%s%s%s\n", + "šŸ”®", bold, cyan, appName, reset, bold, green, envName, reset) + + // Organize by path + paths := map[string][]sdk.SecretResult{} + for _, s := range secrets { + path := s.Path + if path == "" { + path = "/" + } + paths[path] = append(paths[path], s) + } + + // Sort paths + var sortedPaths []string + for p := range paths { + sortedPaths = append(sortedPaths, p) + } + sort.Strings(sortedPaths) + + termWidth := getTerminalWidth() + + for pi, path := range sortedPaths { + pathSecrets := paths[path] + isLastPath := pi == len(sortedPaths)-1 + pathConnector := "ā”œ" + pathPrefix := "│" + if isLastPath { + pathConnector = "ā””" + pathPrefix = " " + } + + fmt.Printf(" %s── %s Path: %s - %s%s%d Secrets%s\n", + pathConnector, "šŸ“", path, bold, magenta, len(pathSecrets), reset) + + // Separate static and dynamic secrets + var staticSecrets []sdk.SecretResult + dynamicGroups := map[string][]sdk.SecretResult{} + var dynamicGroupOrder []string + for _, s := range pathSecrets { + if s.IsDynamic { + if _, seen := dynamicGroups[s.DynamicGroup]; !seen { + dynamicGroupOrder = append(dynamicGroupOrder, s.DynamicGroup) + } + dynamicGroups[s.DynamicGroup] = append(dynamicGroups[s.DynamicGroup], s) + } else { + staticSecrets = append(staticSecrets, s) + } + } + + // Calculate column widths + minKeyWidth := 15 + maxKeyLen := minKeyWidth + for _, s := range pathSecrets { + kl := displayWidth(s.Key) + 4 + if kl > maxKeyLen { + maxKeyLen = kl + } + } + keyWidth := maxKeyLen + 6 + if keyWidth > 40 { + keyWidth = 40 + } + if keyWidth < minKeyWidth { + keyWidth = minKeyWidth + } + // Full row: " X │ " + key + "│ " + value + "│" = prefix(6) + 2 + key + 2 + value + 1 + // Total = keyWidth + valueWidth + 11, must be < termWidth + valueWidth := termWidth - keyWidth - 12 + if valueWidth < 20 { + valueWidth = 20 + } + // Table top + fmt.Fprintf(os.Stdout, " %s ā”Œā”€%s┬─%s┐\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) + fmt.Fprintf(os.Stdout, " %s │ %s%s│ %s%s│\n", + pathPrefix, bold, padRight("KEY", keyWidth)+reset, bold, padRight("VALUE", valueWidth)+reset) + fmt.Fprintf(os.Stdout, " %s ā”œā”€%s┼─%s┤\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) + + // Static secrets + for _, s := range staticSecrets { + renderSecretRow(pathPrefix, s, show, keyWidth, valueWidth) + } + + // Dynamic secret groups + for _, groupLabel := range dynamicGroupOrder { + groupSecrets := dynamicGroups[groupLabel] + + if len(staticSecrets) > 0 || groupLabel != dynamicGroupOrder[0] { + fmt.Fprintf(os.Stdout, " %s ā”œā”€%s┼─%s┤\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) + } + + // Group header spans both columns + header := fmt.Sprintf("⚔ %s", groupLabel) + totalInner := keyWidth + 2 + valueWidth + header = truncateToWidth(header, totalInner) + fmt.Fprintf(os.Stdout, " %s │ %s%s%s│\n", + pathPrefix, bold, padRight(header, totalInner), reset) + fmt.Fprintf(os.Stdout, " %s ā”œā”€%s┼─%s┤\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) + + for _, s := range groupSecrets { + renderSecretRow(pathPrefix, s, show, keyWidth, valueWidth) + } + } + + // Table bottom + fmt.Fprintf(os.Stdout, " %s └─%s┓─%sā”˜\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) + } +} diff --git a/src/pkg/errors/errors.go b/src/pkg/errors/errors.go new file mode 100644 index 00000000..bc072c51 --- /dev/null +++ b/src/pkg/errors/errors.go @@ -0,0 +1,54 @@ +package errors + +import ( + "errors" + "fmt" + + "github.com/phasehq/golang-sdk/phase/network" +) + +// FormatSDKError wraps SDK errors with user-facing presentation (emoji, hints). +// Non-SDK errors pass through unchanged. +func FormatSDKError(err error) string { + var netErr *network.NetworkError + if errors.As(err, &netErr) { + switch netErr.Kind { + case "dns": + return fmt.Sprintf("šŸ—æ Network error: Could not resolve host '%s'. Please check the Phase host URL and your connection", netErr.Host) + case "connection": + return "šŸ—æ Network error: Could not connect to the Phase host. Please check that the server is running and the host URL is correct" + case "timeout": + return "šŸ—æ Network error: Request timed out. Please check your connection and try again" + default: + return fmt.Sprintf("šŸ—æ Network error: %s", netErr.Detail) + } + } + + var sslErr *network.SSLError + if errors.As(err, &sslErr) { + return fmt.Sprintf("šŸ—æ SSL error: %s. You may set PHASE_VERIFY_SSL=False to bypass this check", sslErr.Detail) + } + + var authErr *network.AuthorizationError + if errors.As(err, &authErr) { + if authErr.Detail != "" { + return fmt.Sprintf("🚫 Not authorized: %s", authErr.Detail) + } + return "🚫 Not authorized. Token may be expired or revoked" + } + + var rateLimitErr *network.RateLimitError + if errors.As(err, &rateLimitErr) { + return "ā³ Rate limit exceeded. Please try again later" + } + + var apiErr *network.APIError + if errors.As(err, &apiErr) { + if apiErr.Detail != "" { + return fmt.Sprintf("šŸ—æ Request failed (HTTP %d): %s", apiErr.StatusCode, apiErr.Detail) + } + return fmt.Sprintf("šŸ—æ Request failed with status code %d", apiErr.StatusCode) + } + + return err.Error() +} diff --git a/src/pkg/keyring/keyring.go b/src/pkg/keyring/keyring.go new file mode 100644 index 00000000..c566e543 --- /dev/null +++ b/src/pkg/keyring/keyring.go @@ -0,0 +1,49 @@ +package keyring + +import ( + "fmt" + "os" + + "github.com/phasehq/cli/pkg/config" + gokeyring "github.com/zalando/go-keyring" +) + +func GetCredentials() (string, error) { + // 1. Check PHASE_SERVICE_TOKEN env var + if pss := os.Getenv("PHASE_SERVICE_TOKEN"); pss != "" { + return pss, nil + } + + // 2. Try system keyring + ids, err := config.GetDefaultAccountID(false) + if err != nil { + return "", err + } + if len(ids) == 0 || ids[0] == "" { + return "", fmt.Errorf("no default account configured") + } + accountID := ids[0] + serviceName := fmt.Sprintf("phase-cli-user-%s", accountID) + + pss, err := gokeyring.Get(serviceName, "pss") + if err == nil && pss != "" { + return pss, nil + } + + // 3. Fallback to config file token + return config.GetDefaultUserToken() +} + +func SetCredentials(accountID, token string) error { + serviceName := fmt.Sprintf("phase-cli-user-%s", accountID) + return gokeyring.Set(serviceName, "pss", token) +} + +func DeleteCredentials(accountID string) error { + serviceName := fmt.Sprintf("phase-cli-user-%s", accountID) + err := gokeyring.Delete(serviceName, "pss") + if err == gokeyring.ErrNotFound { + return nil // Not an error if it doesn't exist + } + return err +} diff --git a/src/pkg/phase/phase.go b/src/pkg/phase/phase.go new file mode 100644 index 00000000..215cb839 --- /dev/null +++ b/src/pkg/phase/phase.go @@ -0,0 +1,127 @@ +package phase + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "strings" + + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/version" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/phasehq/golang-sdk/phase/misc" + "github.com/phasehq/golang-sdk/phase/network" +) + +// Create new Phase client. Return host and token +func NewPhase(init bool, pss string, host string) (*sdk.Phase, error) { + if init { + creds, err := keyring.GetCredentials() + if err != nil { + return nil, err + } + pss = creds + h, err := config.GetDefaultUserHost() + if err != nil { + return nil, err + } + host = h + } else { + if pss == "" || host == "" { + return nil, fmt.Errorf("both pss and host must be provided when init is false") + } + } + + setUserAgent() + + return sdk.New(pss, host, false) +} + +func setUserAgent() { + hostname, _ := os.Hostname() + username := "unknown" + if u, err := os.UserHomeDir(); err == nil { + parts := strings.Split(u, string(os.PathSeparator)) + if len(parts) > 0 { + username = parts[len(parts)-1] + } + } + ua := fmt.Sprintf("phase-cli/%s %s %s %s@%s", + version.Version, runtime.GOOS, runtime.GOARCH, username, hostname) + network.SetUserAgent(ua) +} + +func Auth(p *sdk.Phase) error { + _, err := network.FetchAppKey(p.TokenType, p.AppToken, p.Host) + if err != nil { + return fmt.Errorf("invalid Phase credentials: %w", err) + } + return nil +} + +func Init(p *sdk.Phase) (*misc.AppKeyResponse, error) { + resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.Host) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var userData misc.AppKeyResponse + if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { + return nil, fmt.Errorf("failed to decode user data: %w", err) + } + return &userData, nil +} + +// AccountID returns the user_id or account_id from the response. +func AccountID(data *misc.AppKeyResponse) (string, error) { + if data.UserID != "" { + return data.UserID, nil + } + if data.AccountID != "" { + return data.AccountID, nil + } + return "", fmt.Errorf("neither user_id nor account_id found in authentication response") +} + +// PhaseGetContext resolves app/env context from user data, using .phase.json defaults. +func PhaseGetContext(userData *misc.AppKeyResponse, appName, envName, appID string) (string, string, string, string, string, error) { + if appID == "" && appName == "" { + // Find .phase.json config up to 8 dir up (current dir + 8 parent dirs) + phaseConfig := config.FindPhaseConfig(8) + if phaseConfig != nil { + envName = coalesce(envName, phaseConfig.DefaultEnv) + appID = phaseConfig.AppID + } else { + envName = coalesce(envName, "Development") + } + } else { + envName = coalesce(envName, "Development") + } + + return misc.PhaseGetContext(userData, appName, envName, appID) +} + +// GetConfig fills in appName/envName/appID from .phase.json when not provided via flags. +func GetConfig(appName, envName, appID string) (string, string, string) { + if appID == "" && appName == "" { + phaseConfig := config.FindPhaseConfig(8) + if phaseConfig != nil { + envName = coalesce(envName, phaseConfig.DefaultEnv) + appID = phaseConfig.AppID + } + } + if envName == "" { + envName = "Development" + } + return appName, envName, appID +} + +func coalesce(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/src/pkg/util/browser.go b/src/pkg/util/browser.go new file mode 100644 index 00000000..0fd03a9d --- /dev/null +++ b/src/pkg/util/browser.go @@ -0,0 +1,20 @@ +package util + +import ( + "fmt" + "os/exec" + "runtime" +) + +func OpenBrowser(url string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + case "windows": + return exec.Command("cmd", "/c", "start", url).Start() + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} diff --git a/src/pkg/util/color.go b/src/pkg/util/color.go new file mode 100644 index 00000000..d7968998 --- /dev/null +++ b/src/pkg/util/color.go @@ -0,0 +1,70 @@ +package util + +import ( + "fmt" + "os" + "syscall" + + "golang.org/x/term" +) + +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiBoldGreen = "\033[1;32m" + ansiBoldCyan = "\033[1;36m" + ansiBoldMagenta = "\033[1;35m" + ansiBoldYellow = "\033[1;33m" + ansiBoldRed = "\033[1;31m" + ansiBoldWhite = "\033[1;37m" +) + +func stdoutIsTTY() bool { + return term.IsTerminal(int(syscall.Stdout)) +} + +func stderrIsTTY() bool { + return term.IsTerminal(int(syscall.Stderr)) +} + +// wrap applies ANSI codes around text if the given fd is a TTY. +func wrap(code, text string, isTTY bool) string { + if !isTTY { + return text + } + return code + text + ansiReset +} + +// --- stdout helpers --- + +func Bold(text string) string { return wrap(ansiBold, text, stdoutIsTTY()) } +func BoldGreen(text string) string { return wrap(ansiBoldGreen, text, stdoutIsTTY()) } +func BoldCyan(text string) string { return wrap(ansiBoldCyan, text, stdoutIsTTY()) } +func BoldMagenta(text string) string { return wrap(ansiBoldMagenta, text, stdoutIsTTY()) } +func BoldYellow(text string) string { return wrap(ansiBoldYellow, text, stdoutIsTTY()) } +func BoldRed(text string) string { return wrap(ansiBoldRed, text, stdoutIsTTY()) } +func BoldWhite(text string) string { return wrap(ansiBoldWhite, text, stdoutIsTTY()) } + +// --- stderr helpers --- + +func BoldErr(text string) string { return wrap(ansiBold, text, stderrIsTTY()) } +func BoldGreenErr(text string) string { return wrap(ansiBoldGreen, text, stderrIsTTY()) } +func BoldCyanErr(text string) string { return wrap(ansiBoldCyan, text, stderrIsTTY()) } +func BoldMagentaErr(text string) string { return wrap(ansiBoldMagenta, text, stderrIsTTY()) } +func BoldYellowErr(text string) string { return wrap(ansiBoldYellow, text, stderrIsTTY()) } +func BoldRedErr(text string) string { return wrap(ansiBoldRed, text, stderrIsTTY()) } +func BoldWhiteErr(text string) string { return wrap(ansiBoldWhite, text, stderrIsTTY()) } + +// AnsiCodes returns the raw ANSI prefix/reset for use in tree rendering, etc. +// Returns empty strings when stdout is not a TTY. +func AnsiCodes() (bold, cyan, green, magenta, reset string) { + if !stdoutIsTTY() { + return "", "", "", "", "" + } + return ansiBold, "\033[36m", "\033[32m", "\033[35m", ansiReset +} + +// Fprintf convenience: prints a formatted line to stderr with optional color. +func FprintStderr(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, format, a...) +} diff --git a/src/pkg/util/export.go b/src/pkg/util/export.go new file mode 100644 index 00000000..db81fcb1 --- /dev/null +++ b/src/pkg/util/export.go @@ -0,0 +1,123 @@ +package util + +import ( + "encoding/csv" + "encoding/json" + "encoding/xml" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// KeyValue preserves insertion order for deterministic export output. +type KeyValue struct { + Key string + Value string +} + +func ExportDotenv(secrets []KeyValue) { + for _, kv := range secrets { + fmt.Printf("%s=\"%s\"\n", kv.Key, kv.Value) + } +} + +func ExportJSON(secrets []KeyValue) { + // Use json.Encoder to produce an ordered JSON object + ordered := make([]struct { + Key string + Value string + }, len(secrets)) + for i, kv := range secrets { + ordered[i].Key = kv.Key + ordered[i].Value = kv.Value + } + // Build a manually ordered JSON object to preserve key order + fmt.Print("{\n") + for i, kv := range secrets { + keyJSON, _ := json.Marshal(kv.Key) + valJSON, _ := json.Marshal(kv.Value) + fmt.Printf(" %s: %s", string(keyJSON), string(valJSON)) + if i < len(secrets)-1 { + fmt.Print(",") + } + fmt.Println() + } + fmt.Println("}") +} + +func ExportCSV(secrets []KeyValue) { + w := csv.NewWriter(os.Stdout) + w.Write([]string{"Key", "Value"}) + for _, kv := range secrets { + w.Write([]string{kv.Key, kv.Value}) + } + w.Flush() +} + +func ExportYAML(secrets []KeyValue) { + // Build ordered YAML manually to preserve key order + node := &yaml.Node{ + Kind: yaml.MappingNode, + } + for _, kv := range secrets { + node.Content = append(node.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: kv.Key}, + &yaml.Node{Kind: yaml.ScalarNode, Value: kv.Value}, + ) + } + doc := &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{node}, + } + enc := yaml.NewEncoder(os.Stdout) + enc.Encode(doc) + enc.Close() +} + +func ExportXML(secrets []KeyValue) { + fmt.Println("") + for _, kv := range secrets { + var escaped strings.Builder + xml.EscapeText(&escaped, []byte(kv.Value)) + fmt.Printf(" %s\n", kv.Key, escaped.String()) + } + fmt.Println("") +} + +func ExportTOML(secrets []KeyValue) { + for _, kv := range secrets { + fmt.Printf("%s = \"%s\"\n", kv.Key, kv.Value) + } +} + +func ExportHCL(secrets []KeyValue) { + for _, kv := range secrets { + escaped := strings.ReplaceAll(kv.Value, "\"", "\\\"") + fmt.Printf("variable \"%s\" {\n", kv.Key) + fmt.Printf(" default = \"%s\"\n", escaped) + fmt.Println("}") + fmt.Println() + } +} + +func ExportINI(secrets []KeyValue) { + fmt.Println("[DEFAULT]") + for _, kv := range secrets { + escaped := strings.ReplaceAll(kv.Value, "%", "%%") + fmt.Printf("%s = %s\n", kv.Key, escaped) + } +} + +func ExportJavaProperties(secrets []KeyValue) { + for _, kv := range secrets { + fmt.Printf("%s=%s\n", kv.Key, kv.Value) + } +} + +func ExportKV(secrets []KeyValue) { + for _, kv := range secrets { + fmt.Printf("%s=%s\n", kv.Key, kv.Value) + } +} diff --git a/src/pkg/util/export_test.go b/src/pkg/util/export_test.go new file mode 100644 index 00000000..0ef40716 --- /dev/null +++ b/src/pkg/util/export_test.go @@ -0,0 +1,185 @@ +package util + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "encoding/xml" + "io" + "os" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +var sampleSecrets = []KeyValue{ + {Key: "AWS_SECRET_ACCESS_KEY", Value: "abc/xyz"}, + {Key: "AWS_ACCESS_KEY_ID", Value: "AKIA123"}, + {Key: "JWT_SECRET", Value: "token.value"}, + {Key: "DB_PASSWORD", Value: "pass%word"}, +} + +// sampleSecretsMap is a convenience lookup for assertions. +var sampleSecretsMap = map[string]string{ + "AWS_SECRET_ACCESS_KEY": "abc/xyz", + "AWS_ACCESS_KEY_ID": "AKIA123", + "JWT_SECRET": "token.value", + "DB_PASSWORD": "pass%word", +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + original := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("create pipe: %v", err) + } + os.Stdout = w + + fn() + + _ = w.Close() + os.Stdout = original + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read stdout: %v", err) + } + _ = r.Close() + return buf.String() +} + +func parseKeyValueLines(t *testing.T, out string) map[string]string { + t.Helper() + parsed := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + t.Fatalf("invalid key-value line: %q", line) + } + parsed[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + return parsed +} + +func TestExportJSON(t *testing.T) { + out := captureStdout(t, func() { ExportJSON(sampleSecrets) }) + + var got map[string]string + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal json output: %v", err) + } + if len(got) != len(sampleSecretsMap) { + t.Fatalf("unexpected key count: got %d want %d", len(got), len(sampleSecretsMap)) + } + for k, v := range sampleSecretsMap { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} + +func TestExportCSV(t *testing.T) { + out := captureStdout(t, func() { ExportCSV(sampleSecrets) }) + + reader := csv.NewReader(strings.NewReader(out)) + records, err := reader.ReadAll() + if err != nil { + t.Fatalf("read csv: %v", err) + } + if len(records) < 1 || len(records[0]) != 2 || records[0][0] != "Key" || records[0][1] != "Value" { + t.Fatalf("unexpected csv header: %#v", records) + } + + got := map[string]string{} + for _, row := range records[1:] { + if len(row) != 2 { + t.Fatalf("unexpected csv row width: %#v", row) + } + got[row[0]] = row[1] + } + for k, v := range sampleSecretsMap { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} + +func TestExportYAML(t *testing.T) { + out := captureStdout(t, func() { ExportYAML(sampleSecrets) }) + + var got map[string]string + if err := yaml.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal yaml output: %v", err) + } + for k, v := range sampleSecretsMap { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} + +type xmlSecrets struct { + Entries []struct { + Name string `xml:"name,attr"` + Value string `xml:",chardata"` + } `xml:"secret"` +} + +func TestExportXML(t *testing.T) { + out := captureStdout(t, func() { ExportXML(sampleSecrets) }) + + var parsed xmlSecrets + if err := xml.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal xml output: %v", err) + } + got := map[string]string{} + for _, e := range parsed.Entries { + got[e.Name] = e.Value + } + for k, v := range sampleSecretsMap { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} + +func TestExportDotenvAndKVLikeFormats(t *testing.T) { + dotenvOut := captureStdout(t, func() { ExportDotenv(sampleSecrets) }) + dotenv := parseKeyValueLines(t, dotenvOut) + for k, v := range sampleSecretsMap { + if dotenv[k] != `"`+v+`"` { + t.Fatalf("dotenv mismatch for %s: got %q want %q", k, dotenv[k], `"`+v+`"`) + } + } + + kvOut := captureStdout(t, func() { ExportKV(sampleSecrets) }) + kv := parseKeyValueLines(t, kvOut) + for k, v := range sampleSecretsMap { + if kv[k] != v { + t.Fatalf("kv mismatch for %s: got %q want %q", k, kv[k], v) + } + } + + javaOut := captureStdout(t, func() { ExportJavaProperties(sampleSecrets) }) + javaProps := parseKeyValueLines(t, javaOut) + for k, v := range sampleSecretsMap { + if javaProps[k] != v { + t.Fatalf("java properties mismatch for %s: got %q want %q", k, javaProps[k], v) + } + } +} + +func TestExportINI_EscapesPercent(t *testing.T) { + out := captureStdout(t, func() { ExportINI(sampleSecrets) }) + if !strings.HasPrefix(out, "[DEFAULT]\n") { + t.Fatalf("expected ini [DEFAULT] header, got %q", out) + } + if !strings.Contains(out, "DB_PASSWORD = pass%%word") { + t.Fatalf("expected escaped percent in ini output, got %q", out) + } +} diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go new file mode 100644 index 00000000..73487170 --- /dev/null +++ b/src/pkg/util/misc.go @@ -0,0 +1,106 @@ +package util + +import ( + "bufio" + "fmt" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + + sdk "github.com/phasehq/golang-sdk/phase" +) + +// ParseEnvFile parses a .env file +func ParseEnvFile(path string) ([]sdk.KeyValuePair, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var pairs []sdk.KeyValuePair + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { + continue + } + idx := strings.Index(line, "=") + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + value = sanitizeValue(value) + pairs = append(pairs, sdk.KeyValuePair{ + Key: strings.ToUpper(key), + Value: value, + }) + } + return pairs, scanner.Err() +} + +func sanitizeValue(value string) string { + if len(value) >= 2 { + if (value[0] == '\'' && value[len(value)-1] == '\'') || + (value[0] == '"' && value[len(value)-1] == '"') { + return value[1 : len(value)-1] + } + } + return value +} + +func GetDefaultShell() []string { + if runtime.GOOS == "windows" { + if p, err := exec.LookPath("pwsh"); err == nil { + _ = p + return []string{"pwsh"} + } + if p, err := exec.LookPath("powershell"); err == nil { + _ = p + return []string{"powershell"} + } + return []string{"cmd"} + } + + shell := os.Getenv("SHELL") + if shell != "" { + if _, err := os.Stat(shell); err == nil { + return []string{shell} + } + } + + for _, sh := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} { + if _, err := os.Stat(sh); err == nil { + return []string{sh} + } + } + return nil +} + + +func ParseBoolFlag(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "false", "no", "0": + return false + default: + return true + } +} + +func GetShellCommand(shellType string) ([]string, error) { + shell := strings.ToLower(shellType) + path, err := exec.LookPath(shell) + if err != nil { + return nil, fmt.Errorf("shell '%s' not found in PATH: %w", shell, err) + } + return []string{path}, nil +} + +// ValidateURL checks that a URL has both a scheme (e.g. https) and a host (e.g. example.com). +func ValidateURL(rawURL string) bool { + parsed, err := url.Parse(rawURL) + if err != nil { + return false + } + return parsed.Scheme != "" && parsed.Host != "" +} diff --git a/src/pkg/util/misc_test.go b/src/pkg/util/misc_test.go new file mode 100644 index 00000000..28c97327 --- /dev/null +++ b/src/pkg/util/misc_test.go @@ -0,0 +1,92 @@ +package util + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateURL(t *testing.T) { + valid := []string{ + "https://example.com", + "https://console.phase.dev", + "http://localhost:8080", + "https://phase.internal.company.com/api", + "https://10.0.0.1:3000", + } + for _, u := range valid { + if !ValidateURL(u) { + t.Fatalf("expected valid for %q", u) + } + } + + invalid := []string{ + "example.com", + "just-a-hostname", + "://missing-scheme", + "", + "ftp//no-colon.com", + } + for _, u := range invalid { + if ValidateURL(u) { + t.Fatalf("expected invalid for %q", u) + } + } +} + +func TestParseBoolFlag(t *testing.T) { + falseCases := []string{"false", "FALSE", "no", "0", " no "} + for _, tc := range falseCases { + if ParseBoolFlag(tc) { + t.Fatalf("expected false for %q", tc) + } + } + + trueCases := []string{"true", "yes", "1", "", "random"} + for _, tc := range trueCases { + if !ParseBoolFlag(tc) { + t.Fatalf("expected true for %q", tc) + } + } +} + +func TestParseEnvFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + content := `# comment +FOO=bar +lower_case = "quoted value" +SINGLE='abc' +NO_EQUALS +SPACED = value with spaces +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + + pairs, err := ParseEnvFile(path) + if err != nil { + t.Fatalf("parse env file: %v", err) + } + + got := map[string]string{} + for _, p := range pairs { + got[p.Key] = p.Value + } + + want := map[string]string{ + "FOO": "bar", + "LOWER_CASE": "quoted value", + "SINGLE": "abc", + "SPACED": "value with spaces", + } + + if len(got) != len(want) { + t.Fatalf("unexpected key count: got %d want %d (%v)", len(got), len(want), got) + } + for k, v := range want { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} diff --git a/src/pkg/util/spinner.go b/src/pkg/util/spinner.go new file mode 100644 index 00000000..11e1eef2 --- /dev/null +++ b/src/pkg/util/spinner.go @@ -0,0 +1,69 @@ +package util + +import ( + "fmt" + "os" + "sync" + "syscall" + "time" + + "golang.org/x/term" +) + +var spinnerFrames = []string{"ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā "} + +// Spinner is a transient braille-dot spinner that writes to stderr. +type Spinner struct { + message string + stop chan struct{} + done sync.WaitGroup +} + +// NewSpinner creates a spinner with the given message. +func NewSpinner(message string) *Spinner { + return &Spinner{ + message: message, + stop: make(chan struct{}), + } +} + +// Start begins the spinner animation in a background goroutine. +func (s *Spinner) Start() { + if !term.IsTerminal(int(syscall.Stderr)) { + return + } + + s.done.Add(1) + go func() { + defer s.done.Done() + i := 0 + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-s.stop: + // Clear the spinner line + fmt.Fprintf(os.Stderr, "\r\033[K") + return + case <-ticker.C: + frame := spinnerFrames[i%len(spinnerFrames)] + msg := BoldGreenErr(s.message) + fmt.Fprintf(os.Stderr, "\r%s %s", frame, msg) + i++ + } + } + }() +} + +// Stop halts the spinner and clears the line. +func (s *Spinner) Stop() { + select { + case <-s.stop: + // Already stopped + return + default: + close(s.stop) + } + s.done.Wait() +} diff --git a/src/pkg/version/version.go b/src/pkg/version/version.go new file mode 100644 index 00000000..88f89638 --- /dev/null +++ b/src/pkg/version/version.go @@ -0,0 +1,4 @@ +package version + +// Version is the CLI version. Override via -ldflags at build time. +var Version = "2.0.0"