diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..23bc9344e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,145 @@ +name: Bug report +description: Report a user-facing bug or regression. +labels: + - "type: bug" +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this. + + **Privacy note:** Please don’t paste secrets, tokens, or private logs. + If you can reproduce in a minimal way, that’s ideal. If you can’t, still file the issue. + + - type: textarea + id: summary + attributes: + label: Summary + description: What’s broken, and what’s the user impact? + placeholder: One or two sentences. + validations: + required: true + + - type: textarea + id: current_behavior + attributes: + label: What happened (current behavior) + description: What did you observe? + placeholder: Describe what happened. + validations: + required: false + + - type: textarea + id: expected_behavior + attributes: + label: Expected behavior + description: Optional. + placeholder: What should happen? + validations: + required: false + + - type: textarea + id: reproduction_steps + attributes: + label: Reproduction steps + description: Optional. If you can reproduce, list steps here. + placeholder: | + 1. … + 2. … + 3. … + validations: + required: false + + - type: dropdown + id: severity + attributes: + label: Severity + description: Optional. Best guess is fine. + options: + - blocker + - high + - medium + - low + - unsure + validations: + required: false + + - type: dropdown + id: frequency + attributes: + label: Frequency + description: Optional. Best guess is fine. + options: + - always + - often + - sometimes + - once + - unsure + validations: + required: false + + - type: input + id: version + attributes: + label: Happier version + description: App/CLI version (if known). + placeholder: e.g. 0.12.3 or "main @ " + validations: + required: false + + - type: input + id: platform + attributes: + label: Platform + description: OS/device details. + placeholder: e.g. macOS 15.2, Windows 11, iOS 18, Ubuntu 24.04 + validations: + required: false + + - type: input + id: server_version + attributes: + label: Server version + description: Optional. If relevant, the server version you’re connected to. + placeholder: e.g. 0.12.3 + validations: + required: false + + - type: dropdown + id: deployment_type + attributes: + label: Deployment type + description: Optional. If you know it. + options: + - cloud + - self-hosted + - enterprise + - unsure + validations: + required: false + + - type: textarea + id: what_changed + attributes: + label: What changed recently? + description: Optional. Updates, config changes, new hardware, etc. + placeholder: e.g. Updated from 0.12.2 → 0.12.3, changed model settings, switched networks. + validations: + required: false + + - type: input + id: diagnostic_id + attributes: + label: Diagnostics ID + description: Optional. If you submitted an in-app bug report, paste the diagnostic id line here (include the `diagnostic_id:` prefix). + placeholder: "diagnostic_id: 00000000-0000-0000-0000-000000000000" + validations: + required: false + + - type: textarea + id: extra + attributes: + label: Additional context + description: Screenshots, links to related issues/PRs, anything else. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..84e906cea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +blank_issues_enabled: true +contact_links: + - name: Happier docs + url: https://happier.dev + about: Product docs and guides. + - name: Security issue + url: https://github.com/happier-dev/happier/security/policy + about: Please report security vulnerabilities privately. + diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..d0fd84986 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,71 @@ +name: Feature request +description: Propose a user-facing feature or improvement. +labels: + - "type: feature" +body: + - type: markdown + attributes: + value: | + Thanks for proposing an improvement. + + **Privacy note:** Please don’t paste secrets, tokens, or private logs. + + - type: textarea + id: problem + attributes: + label: Problem statement + description: What user problem are we solving? Who is impacted? + placeholder: | + As a …, I want …, so that … + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Optional. If you have an idea, describe what we should build/change. + validations: + required: false + + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: Optional. How do we know we’re done? A short checklist is ideal. + placeholder: | + - [ ] … + - [ ] … + validations: + required: false + + - type: textarea + id: non_goals + attributes: + label: Non-goals (optional) + description: What is explicitly out of scope? + placeholder: | + - … + validations: + required: false + + - type: dropdown + id: priority + attributes: + label: Priority (optional) + description: Best guess is fine; maintainers may adjust. + options: + - P0 + - P1 + - P2 + - P3 + validations: + required: false + + - type: textarea + id: notes + attributes: + label: Additional context (optional) + description: Links, mockups, references, related issues. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml new file mode 100644 index 000000000..bf1bd94df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -0,0 +1,30 @@ +name: Task / Chore +description: Internal cleanup, refactor, infra, or small scoped work. +labels: + - "type: task" +body: + - type: textarea + id: summary + attributes: + label: Summary + placeholder: What are we doing? + validations: + required: true + + - type: textarea + id: scope + attributes: + label: Scope / checklist + placeholder: | + - [ ] … + - [ ] … + validations: + required: false + + - type: textarea + id: context + attributes: + label: Context (optional) + description: Links to code, issues, or background. + validations: + required: false diff --git a/.github/actions/release-actor-guard/action.yml b/.github/actions/release-actor-guard/action.yml index 215128eef..2fc35734f 100644 --- a/.github/actions/release-actor-guard/action.yml +++ b/.github/actions/release-actor-guard/action.yml @@ -18,6 +18,10 @@ inputs: description: GitHub App private key used to check team membership (optional; when omitted, guard falls back to repo-admin check) required: false default: "" + trusted_actors: + description: "Comma-separated list of explicitly trusted actors (e.g. a release bot) that can bypass team membership checks" + required: false + default: "" runs: using: composite @@ -65,12 +69,25 @@ runs: ACTOR: ${{ steps.params.outputs.actor }} ORG: ${{ steps.params.outputs.org }} TEAM_SLUG: ${{ steps.params.outputs.team_slug }} + INPUT_TRUSTED_ACTORS: ${{ inputs.trusted_actors }} APP_TOKEN: ${{ steps.app_token.outputs.token }} REPO: ${{ github.repository }} REPO_TOKEN: ${{ github.token }} run: | set -euo pipefail + trusted_csv="${INPUT_TRUSTED_ACTORS:-}" + if [ -n "${trusted_csv}" ]; then + IFS=',' read -r -a trusted <<< "${trusted_csv}" + for raw in "${trusted[@]}"; do + actor="$(echo "${raw}" | xargs)" + if [ -n "${actor}" ] && [ "${actor}" = "${ACTOR}" ]; then + echo "Actor '${ACTOR}' is explicitly trusted; allowing release workflow." + exit 0 + fi + done + fi + fallback_repo_admin() { if [ -z "${REPO_TOKEN:-}" ]; then echo "Missing repo token for release actor guard fallback." >&2 @@ -81,7 +98,8 @@ runs: exit 1 fi - endpoint="https://api.github.com/repos/${REPO}/collaborators/${ACTOR}/permission" + actor_enc="$(jq -nr --arg v "${ACTOR}" '$v|@uri')" + endpoint="https://api.github.com/repos/${REPO}/collaborators/${actor_enc}/permission" resp_file="$(mktemp)" status="$(curl -sS \ --connect-timeout 10 \ @@ -116,7 +134,8 @@ runs: exit 0 fi - endpoint="https://api.github.com/orgs/${ORG}/teams/${TEAM_SLUG}/memberships/${ACTOR}" + actor_enc="$(jq -nr --arg v "${ACTOR}" '$v|@uri')" + endpoint="https://api.github.com/orgs/${ORG}/teams/${TEAM_SLUG}/memberships/${actor_enc}" resp_file="$(mktemp)" status="$(curl -sS \ --connect-timeout 10 \ diff --git a/.github/workflows/build-tauri.yml b/.github/workflows/build-tauri.yml index 9270bddd1..7597d710c 100644 --- a/.github/workflows/build-tauri.yml +++ b/.github/workflows/build-tauri.yml @@ -96,6 +96,15 @@ jobs: environment: release-shared env: HAPPIER_EMBEDDED_POLICY_ENV: ${{ inputs.environment == 'production' && 'production' || 'preview' }} + # Secrets are set at the job-level so `if:` expressions can gate on `env.*` without referencing + # `secrets.*` (the `secrets` context is not available in workflow expression contexts). + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} strategy: fail-fast: false matrix: @@ -144,7 +153,7 @@ jobs: if: inputs.environment == 'production' run: | set -euo pipefail - node scripts/pipeline/tauri/validate-updater-pubkey.mjs + node scripts/pipeline/run.mjs tauri-validate-updater-pubkey --config-path apps/ui/src-tauri/tauri.conf.json - name: Enable Corepack (Yarn) run: | @@ -179,9 +188,6 @@ jobs: - name: Import Apple codesigning certificate (macOS) if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' && env.APPLE_CERTIFICATE_PASSWORD != '' - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | set -euo pipefail @@ -206,9 +212,6 @@ jobs: - name: Resolve Apple signing identity (macOS) if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' && env.APPLE_CERTIFICATE_PASSWORD != '' id: apple_id - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | set -euo pipefail identity="$(security find-identity -v -p codesigning 2>&1 | awk -F '"' '/Developer ID Application:/ { print $2; exit }')" @@ -251,23 +254,6 @@ jobs: echo "ui_version=${ui_version}" >> "$GITHUB_OUTPUT" echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" - - name: Materialize Tauri signing key (if configured) - if: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} - env: - TAURI_SIGNING_PRIVATE_KEY_RAW: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - if [ -z "${RUNNER_TEMP:-}" ]; then - echo "RUNNER_TEMP is required to materialize signing key." >&2 - exit 1 - fi - key_path="${RUNNER_TEMP}/tauri.signing.key" - printf '%s' "${TAURI_SIGNING_PRIVATE_KEY_RAW}" > "${key_path}" - chmod 600 "${key_path}" || true - echo "TAURI_SIGNING_PRIVATE_KEY=${key_path}" >> "$GITHUB_ENV" - echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${TAURI_SIGNING_PRIVATE_KEY_PASSWORD}" >> "$GITHUB_ENV" - - name: Build desktop updater artifacts env: CI: "true" @@ -276,178 +262,21 @@ jobs: APPLE_SIGNING_IDENTITY: ${{ steps.apple_id.outputs.identity }} run: | set -euo pipefail - cd apps/ui - - if [ -n "${TAURI_TARGET}" ]; then - rustup target add "${TAURI_TARGET}" - fi - - target_args=() - if [ -n "${TAURI_TARGET}" ]; then - target_args+=(--target "${TAURI_TARGET}") - fi - - configs=() - - # Updater artifacts are disabled by default in src-tauri/tauri.conf.json so local builds do not require - # a signing key. In CI, explicitly enable them only when the signing private key is available. - if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then - updater_override="" - if [ -n "${RUNNER_TEMP:-}" ]; then - updater_override="${RUNNER_TEMP}/tauri.updater.override.json" - elif updater_override_tmp="$(mktemp 2>/dev/null)"; then - updater_override="${updater_override_tmp}" - else - updater_override="/tmp/tauri.updater.override.json" - fi - if [ -z "${updater_override:-}" ]; then - echo "Unable to allocate a temp file path for Tauri updater override." >&2 - exit 1 - fi - mkdir -p "$(dirname "${updater_override}")" - touch "${updater_override}" || { - echo "Updater override file path is not writable: ${updater_override}" >&2 - exit 1 - } - node -e ' - const fs = require("node:fs"); - const out = process.argv[1]; - const payload = { bundle: { createUpdaterArtifacts: true } }; - fs.writeFileSync(out, `${JSON.stringify(payload)}\n`, "utf8"); - ' "${updater_override}" - configs+=(--config "${updater_override}") - fi - - # macOS codesigning (optional). If APPLE_CERTIFICATE is configured, sign the app during the build. - if [ "${RUNNER_OS}" = "macOS" ] && [ -n "${APPLE_SIGNING_IDENTITY}" ]; then - codesign_override="${RUNNER_TEMP}/tauri.codesign.override.json" - node -e ' - const fs = require("node:fs"); - const path = process.argv[1]; - const signingIdentity = String(process.env.APPLE_SIGNING_IDENTITY ?? ""); - const payload = { bundle: { macOS: { signingIdentity, hardenedRuntime: true } } }; - fs.writeFileSync(path, `${JSON.stringify(payload)}\n`, "utf8"); - ' "${codesign_override}" - configs+=(--config "${codesign_override}") - fi - - if [ "${{ inputs.environment }}" = "preview" ]; then - override="" - if [ -n "${RUNNER_TEMP:-}" ]; then - override="${RUNNER_TEMP}/tauri.version.override.json" - elif override_tmp="$(mktemp 2>/dev/null)"; then - override="${override_tmp}" - else - override="/tmp/tauri.version.override.json" - fi - if [ -z "${override:-}" ]; then - echo "Unable to allocate a temp file path for Tauri version override." >&2 - exit 1 - fi - mkdir -p "$(dirname "${override}")" - touch "${override}" || { - echo "Version override file path is not writable: ${override}" >&2 - exit 1 - } - VERSION_OVERRIDE="${{ steps.ver.outputs.build_version }}" node -e ' - const fs = require("node:fs"); - const out = process.argv[1]; - const version = String(process.env.VERSION_OVERRIDE ?? ""); - fs.writeFileSync(out, `${JSON.stringify({ version })}\n`, "utf8"); - ' "${override}" - yarn tauri build --config src-tauri/tauri.preview.conf.json --config "${override}" "${configs[@]}" "${target_args[@]}" - else - yarn tauri build "${configs[@]}" "${target_args[@]}" - fi + node scripts/pipeline/run.mjs tauri-build-updater-artifacts \ + --environment "${{ inputs.environment }}" \ + --build-version "${{ steps.ver.outputs.build_version }}" \ + --tauri-target "${TAURI_TARGET}" \ + --ui-dir "apps/ui" - name: Notarize macOS artifacts (updater + DMG) (macOS) if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' && env.APPLE_CERTIFICATE_PASSWORD != '' && env.APPLE_API_KEY_ID != '' && env.APPLE_API_ISSUER_ID != '' && env.APPLE_API_PRIVATE_KEY != '' && env.TAURI_SIGNING_PRIVATE_KEY != '' env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} - APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} TAURI_TARGET: ${{ matrix.tauri_target }} run: | set -euo pipefail - - cd apps/ui - - base_dir="src-tauri/target" - search_dir="${base_dir}" - if [ -n "${TAURI_TARGET}" ]; then - search_dir="${base_dir}/${TAURI_TARGET}" - fi - - sig_matches=() - while IFS= read -r match; do - sig_matches+=("${match}") - done < <(find "${search_dir}" -path '*/release/bundle/*' -type f -name '*.app.tar.gz.sig' | sort) - if [ "${#sig_matches[@]}" -ne 1 ]; then - echo "Expected exactly one macOS updater signature under ${search_dir}; found ${#sig_matches[@]}" >&2 - if [ "${#sig_matches[@]}" -gt 0 ]; then - printf 'Matches:\n' >&2 - printf ' %s\n' "${sig_matches[@]}" >&2 - fi - exit 1 - fi - sig_path="${sig_matches[0]}" - artifact_path="${sig_path%.sig}" - if [ ! -f "${artifact_path}" ]; then - echo "Missing updater artifact for signature: ${sig_path}" >&2 - exit 1 - fi - - key_path="${RUNNER_TEMP}/apple-notary.p8" - normalized_notary_key="$(node -e 'const raw=String(process.env.APPLE_API_PRIVATE_KEY ?? ""); process.stdout.write(raw.includes("\\n") ? raw.replaceAll("\\n", "\n") : raw);')" - if printf "%s" "${normalized_notary_key}" | grep -q "BEGIN PRIVATE KEY"; then - printf "%s" "${normalized_notary_key}" > "${key_path}" - else - # Support storing the key as base64 (common for multi-line secrets). - printf "%s" "${normalized_notary_key}" | base64 --decode > "${key_path}" - fi - chmod 600 "${key_path}" - - work="$(mktemp -d)" - tar -xzf "${artifact_path}" -C "${work}" - app_path="$(find "${work}" -maxdepth 3 -type d -name '*.app' | head -n 1 || true)" - if [ -z "${app_path:-}" ]; then - echo "Unable to find .app inside updater artifact: ${artifact_path}" >&2 - exit 1 - fi - - zip_path="${work}/app.zip" - ditto -c -k --keepParent "${app_path}" "${zip_path}" - xcrun notarytool submit "${zip_path}" --key "${key_path}" --key-id "${APPLE_API_KEY_ID}" --issuer "${APPLE_API_ISSUER_ID}" --wait --timeout 15m - xcrun stapler staple "${app_path}" - - # Re-pack the tar.gz so the updater payload contains the stapled ticket. - app_name="$(basename "${app_path}")" - app_parent="$(dirname "${app_path}")" - new_tar="${work}/notarized.app.tar.gz" - tar -czf "${new_tar}" -C "${app_parent}" "${app_name}" - mv "${new_tar}" "${artifact_path}" - - # Re-sign the updated archive so latest.json signature matches. - artifact_abs="$(cd "$(dirname "${artifact_path}")" && pwd)/$(basename "${artifact_path}")" - sig_abs="$(cd "$(dirname "${sig_path}")" && pwd)/$(basename "${sig_path}")" - tmp_sig="${work}/notarized.sig" - yarn --silent tauri signer sign "${artifact_abs}" > "${tmp_sig}" - sig_value="$(tr -d '\r\n' < "${tmp_sig}")" - if [ -z "${sig_value}" ] || ! printf "%s" "${sig_value}" | grep -Eq '^[A-Za-z0-9+/=]+$'; then - echo "Generated updater signature is invalid: ${tmp_sig}" >&2 - exit 1 - fi - printf "%s\n" "${sig_value}" > "${tmp_sig}" - mv "${tmp_sig}" "${sig_abs}" - - # Notarize DMG too (if present) so the installer is Gatekeeper-friendly. - dmg_path="$(find "${search_dir}" -path '*/release/bundle/*' -type f -name '*.dmg' | head -n 1 || true)" - if [ -n "${dmg_path:-}" ]; then - xcrun notarytool submit "${dmg_path}" --key "${key_path}" --key-id "${APPLE_API_KEY_ID}" --issuer "${APPLE_API_ISSUER_ID}" --wait --timeout 15m - xcrun stapler staple "${dmg_path}" - fi + node scripts/pipeline/run.mjs tauri-notarize-macos-artifacts \ + --ui-dir "apps/ui" \ + --tauri-target "${TAURI_TARGET}" - name: Fail when production notarization/signing secrets are missing (macOS) if: runner.os == 'macOS' && inputs.environment == 'production' && (env.APPLE_CERTIFICATE == '' || env.APPLE_CERTIFICATE_PASSWORD == '' || env.APPLE_API_KEY_ID == '' || env.APPLE_API_ISSUER_ID == '' || env.APPLE_API_PRIVATE_KEY == '' || env.TAURI_SIGNING_PRIVATE_KEY == '') @@ -460,131 +289,16 @@ jobs: env: PLATFORM_KEY: ${{ matrix.platform_key }} UI_VERSION: ${{ steps.ver.outputs.ui_version }} - BUILD_VERSION: ${{ steps.ver.outputs.build_version }} ENVIRONMENT: ${{ inputs.environment }} TAURI_TARGET: ${{ matrix.tauri_target }} run: | set -euo pipefail - - base_dir="apps/ui/src-tauri/target" - search_dir="${base_dir}" - if [ -n "${TAURI_TARGET}" ]; then - search_dir="${base_dir}/${TAURI_TARGET}" - fi - - sig_path="" - case "${PLATFORM_KEY}" in - windows-*) - sig_matches=() - while IFS= read -r match; do - sig_matches+=("${match}") - done < <(find "${search_dir}" -path '*/release/bundle/*' -type f \( -name '*.msi.zip.sig' -o -name '*.exe.zip.sig' -o -name '*.nsis.zip.sig' \) | sort) - if [ "${#sig_matches[@]}" -lt 1 ]; then - echo "Expected at least one updater signature for PLATFORM_KEY=${PLATFORM_KEY} under ${search_dir}; found 0" >&2 - exit 1 - fi - preferred_sig="" - for candidate in "${sig_matches[@]}"; do - if [[ "${candidate}" == *.nsis.zip.sig ]]; then - preferred_sig="${candidate}" - break - fi - done - if [ -z "${preferred_sig}" ]; then - for candidate in "${sig_matches[@]}"; do - if [[ "${candidate}" == *.exe.zip.sig ]]; then - preferred_sig="${candidate}" - break - fi - done - fi - if [ -z "${preferred_sig}" ]; then - preferred_sig="${sig_matches[0]}" - fi - if [ "${#sig_matches[@]}" -gt 1 ]; then - echo "Found multiple Windows updater signatures for ${PLATFORM_KEY}; using preferred artifact: ${preferred_sig}" >&2 - printf 'All matches:\n' >&2 - printf ' %s\n' "${sig_matches[@]}" >&2 - fi - sig_path="${preferred_sig}" - ;; - darwin-*) - sig_matches=() - while IFS= read -r match; do - sig_matches+=("${match}") - done < <(find "${search_dir}" -path '*/release/bundle/*' -type f -name '*.app.tar.gz.sig' | sort) - if [ "${#sig_matches[@]}" -ne 1 ]; then - echo "Expected exactly one updater signature for PLATFORM_KEY=${PLATFORM_KEY} under ${search_dir}; found ${#sig_matches[@]}" >&2 - if [ "${#sig_matches[@]}" -gt 0 ]; then - printf 'Matches:\n' >&2 - printf ' %s\n' "${sig_matches[@]}" >&2 - fi - exit 1 - fi - sig_path="${sig_matches[0]}" - ;; - linux-*) - sig_matches=() - while IFS= read -r match; do - sig_matches+=("${match}") - done < <(find "${search_dir}" -path '*/release/bundle/*' -type f \( -name '*.AppImage.sig' -o -name '*.appimage.sig' -o -name '*.AppImage.tar.gz.sig' -o -name '*.appimage.tar.gz.sig' \) | sort) - if [ "${#sig_matches[@]}" -ne 1 ]; then - echo "Expected exactly one updater signature for PLATFORM_KEY=${PLATFORM_KEY} under ${search_dir}; found ${#sig_matches[@]}" >&2 - if [ "${#sig_matches[@]}" -gt 0 ]; then - printf 'Matches:\n' >&2 - printf ' %s\n' "${sig_matches[@]}" >&2 - fi - exit 1 - fi - sig_path="${sig_matches[0]}" - ;; - *) - echo "Unknown platform_key: ${PLATFORM_KEY}" >&2 - exit 1 - ;; - esac - - if [ -z "${sig_path:-}" ]; then - echo "Unable to find updater signature under: ${search_dir}/**/release/bundle" >&2 - exit 1 - fi - - artifact_path="${sig_path%.sig}" - if [ ! -f "${artifact_path}" ]; then - echo "Missing updater artifact for signature: ${sig_path}" >&2 - exit 1 - fi - - artifact_filename="$(basename "${artifact_path}")" - ext="" - case "${artifact_filename}" in - *.msi.zip) ext=".msi.zip" ;; - *.exe.zip) ext=".exe.zip" ;; - *.nsis.zip) ext=".nsis.zip" ;; - *.app.tar.gz) ext=".app.tar.gz" ;; - *.AppImage.tar.gz) ext=".AppImage.tar.gz" ;; - *.appimage.tar.gz) ext=".appimage.tar.gz" ;; - *) ext=".${artifact_filename##*.}" ;; - esac - - if [ "${ENVIRONMENT}" = "preview" ]; then - out_base="happier-ui-desktop-preview-${PLATFORM_KEY}" - else - out_base="happier-ui-desktop-${PLATFORM_KEY}-v${UI_VERSION}" - fi - - out_dir="dist/tauri/updates/${PLATFORM_KEY}" - mkdir -p "${out_dir}" - cp "${artifact_path}" "${out_dir}/${out_base}${ext}" - cp "${sig_path}" "${out_dir}/${out_base}${ext}.sig" - - # Include macOS installer DMG (if present). Not referenced by latest.json (no .sig), but useful for manual installs. - if [[ "${PLATFORM_KEY}" == darwin-* ]]; then - dmg_path="$(find "${search_dir}" -path '*/release/bundle/*' -type f -name '*.dmg' | head -n 1 || true)" - if [ -n "${dmg_path:-}" ]; then - cp "${dmg_path}" "${out_dir}/${out_base}.dmg" - fi - fi + node scripts/pipeline/run.mjs tauri-collect-updater-artifacts \ + --environment "${ENVIRONMENT}" \ + --platform-key "${PLATFORM_KEY}" \ + --ui-version "${UI_VERSION}" \ + --tauri-target "${TAURI_TARGET}" \ + --ui-dir "apps/ui" - name: Upload updater assets artifact uses: actions/upload-artifact@v4 @@ -607,6 +321,11 @@ jobs: ref: ${{ needs.resolve_source.outputs.source_sha }} fetch-depth: 1 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Compute build metadata id: meta run: | @@ -632,7 +351,7 @@ jobs: ENVIRONMENT: ${{ inputs.environment }} run: | set -euo pipefail - node scripts/pipeline/tauri/prepare-publish-assets.mjs \ + node scripts/pipeline/run.mjs tauri-prepare-assets \ --environment "${ENVIRONMENT}" \ --ui-version "${{ steps.meta.outputs.ui_version }}" \ --repo "${REPO}" \ diff --git a/.github/workflows/build-ui-mobile-local.yml b/.github/workflows/build-ui-mobile-local.yml index bcd02c219..d818b5478 100644 --- a/.github/workflows/build-ui-mobile-local.yml +++ b/.github/workflows/build-ui-mobile-local.yml @@ -108,22 +108,22 @@ jobs: private_key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} validate_expo_token: - name: Validate EXPO_TOKEN (manual runs) - if: ${{ github.event_name == 'workflow_dispatch' && (inputs.platform == 'all' || inputs.platform == 'android' || inputs.platform == 'ios') }} + name: Validate EXPO_TOKEN + if: ${{ inputs.platform == 'all' || inputs.platform == 'android' || inputs.platform == 'ios' }} runs-on: ubuntu-latest steps: - name: Fail when EXPO_TOKEN is missing run: | set -euo pipefail if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then - echo "EXPO_TOKEN is required for BUILD — UI Mobile (local runner) workflow_dispatch runs." >&2 - echo "Set the repository secret EXPO_TOKEN, or run via workflow_call from a trusted workflow that provides it." >&2 + echo "EXPO_TOKEN is required for BUILD — UI Mobile (local runner)." >&2 + echo "Set the repository secret EXPO_TOKEN, or call this workflow from a trusted workflow that provides it." >&2 exit 1 fi validate_apple_api_private_key: name: Validate APPLE_API_PRIVATE_KEY (iOS submit) - if: ${{ github.event_name == 'workflow_dispatch' && inputs.action == 'build_and_submit' && (inputs.platform == 'all' || inputs.platform == 'ios') }} + if: ${{ inputs.action == 'build_and_submit' && (inputs.platform == 'all' || inputs.platform == 'ios') }} runs-on: ubuntu-latest steps: - name: Fail when APPLE_API_PRIVATE_KEY is missing @@ -137,8 +137,8 @@ jobs: build_android: name: Build (android) - if: ${{ (inputs.platform == 'all' || inputs.platform == 'android') && secrets.EXPO_TOKEN != '' }} - needs: [release_actor_guard] + if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }} + needs: [release_actor_guard, validate_expo_token] runs-on: ubuntu-latest permissions: contents: write @@ -225,8 +225,8 @@ jobs: build_ios: name: Build (ios) - if: ${{ (inputs.platform == 'all' || inputs.platform == 'ios') && secrets.EXPO_TOKEN != '' }} - needs: [release_actor_guard] + if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }} + needs: [release_actor_guard, validate_expo_token] runs-on: macos-latest environment: release-shared env: @@ -287,8 +287,8 @@ jobs: submit_android: name: Submit (android) - if: ${{ inputs.action == 'build_and_submit' && (inputs.platform == 'all' || inputs.platform == 'android') && secrets.EXPO_TOKEN != '' }} - needs: [release_actor_guard, build_android] + if: ${{ inputs.action == 'build_and_submit' && (inputs.platform == 'all' || inputs.platform == 'android') }} + needs: [release_actor_guard, validate_expo_token, build_android] runs-on: ubuntu-latest environment: release-shared env: @@ -397,8 +397,8 @@ jobs: submit_ios: name: Submit (ios) - if: ${{ inputs.action == 'build_and_submit' && (inputs.platform == 'all' || inputs.platform == 'ios') && secrets.EXPO_TOKEN != '' }} - needs: [release_actor_guard, build_ios] + if: ${{ inputs.action == 'build_and_submit' && (inputs.platform == 'all' || inputs.platform == 'ios') }} + needs: [release_actor_guard, validate_expo_token, validate_apple_api_private_key, build_ios] runs-on: macos-latest environment: release-shared env: diff --git a/.github/workflows/cli-smoke-test.yml b/.github/workflows/cli-smoke-test.yml index 290087111..3a6da8449 100644 --- a/.github/workflows/cli-smoke-test.yml +++ b/.github/workflows/cli-smoke-test.yml @@ -16,18 +16,15 @@ on: jobs: smoke-test-linux: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [22] steps: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Node.js ${{ matrix.node-version }} + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 22.x cache: 'yarn' cache-dependency-path: yarn.lock @@ -39,82 +36,20 @@ jobs: HAPPIER_UI_VENDOR_WEB_ASSETS: "0" run: yarn install --frozen-lockfile --ignore-engines - - name: Build package - run: yarn workspace @happier-dev/cli build - - - name: Pack package - run: | - set -euo pipefail - PACKAGE_NAME="$(cd apps/cli && npm pack --silent --pack-destination /tmp)" - PACKAGE_FILE="/tmp/${PACKAGE_NAME}" - if [ ! -f "${PACKAGE_FILE}" ]; then - echo "Unable to find packed CLI tarball at ${PACKAGE_FILE}" >&2 - exit 1 - fi - echo "CLI_PACKAGE_FILE=${PACKAGE_FILE}" >> "$GITHUB_ENV" - - - name: Install packed package globally - run: | - set -euo pipefail - SMOKE_PREFIX="$(mktemp -d)" - SMOKE_HOME="$(mktemp -d)" - npm install -g --prefix "${SMOKE_PREFIX}" "${CLI_PACKAGE_FILE}" - HAPPIER_BIN="${SMOKE_PREFIX}/bin/happier" - if [ ! -x "${HAPPIER_BIN}" ]; then - echo "Expected CLI binary not found at ${HAPPIER_BIN}" >&2 - exit 1 - fi - echo "HAPPIER_BIN=${HAPPIER_BIN}" >> "$GITHUB_ENV" - echo "HAPPIER_HOME_DIR=${SMOKE_HOME}" >> "$GITHUB_ENV" - - - name: Test binary execution - run: | - set -euo pipefail - export HAPPIER_HOME_DIR - # Test that the binary starts successfully - echo "Testing happier --help..." - timeout 30s "${HAPPIER_BIN}" --help || { - echo "Error: happier --help failed or timed out" - exit 1 - } - - echo "Testing happier --version..." - timeout 10s "${HAPPIER_BIN}" --version || { - echo "Error: happier --version failed or timed out" - exit 1 - } - - echo "Testing happier doctor..." - DOCTOR_OUTPUT="$(timeout 10s "${HAPPIER_BIN}" doctor --help)" || { - echo "Error: happier doctor failed or timed out" - exit 1 - } - printf '%s\n' "${DOCTOR_OUTPUT}" - - echo "Testing happier daemon --help..." - DAEMON_HELP_OUTPUT="$(timeout 10s "${HAPPIER_BIN}" daemon --help)" || { - echo "Error: happier daemon --help failed or timed out" - exit 1 - } - printf '%s\n' "${DAEMON_HELP_OUTPUT}" - printf '%s' "${DAEMON_HELP_OUTPUT}" | grep -q 'Daemon management' - - echo "Smoke test passed on Linux!" + - name: CLI smoke (pipeline) + run: node scripts/pipeline/run.mjs smoke-cli smoke-test-windows: runs-on: windows-latest - strategy: - matrix: - node-version: [22] steps: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Node.js ${{ matrix.node-version }} + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 22.x cache: 'yarn' cache-dependency-path: yarn.lock @@ -126,126 +61,7 @@ jobs: HAPPIER_UI_VENDOR_WEB_ASSETS: "0" run: yarn install --frozen-lockfile --ignore-engines - - name: Build package - run: yarn workspace @happier-dev/cli build - - - name: Pack package - run: | - cd apps/cli - npm pack - - - name: Install packed package globally - shell: cmd - run: | - for %%f in (apps\cli\*.tgz) do npm install -g "%%f" - - - name: Debug npm global installation structure - shell: cmd - run: | - for /f "tokens=*" %%i in ('npm config get prefix') do set NPM_PREFIX=%%i - echo NPM_PREFIX: %NPM_PREFIX% - echo Listing npm prefix directory: - dir "%NPM_PREFIX%" 2>nul || echo Failed to list NPM_PREFIX - echo. - echo Checking for node_modules in npm prefix: - if exist "%NPM_PREFIX%\node_modules" ( - echo Found node_modules directory - dir "%NPM_PREFIX%\node_modules" 2>nul - echo. - echo Checking for @happier-dev/cli package: - if exist "%NPM_PREFIX%\node_modules\@happier-dev\cli" ( - echo Found @happier-dev/cli package - dir "%NPM_PREFIX%\node_modules\@happier-dev\cli" 2>nul - echo. - echo Checking for dist directory: - if exist "%NPM_PREFIX%\node_modules\@happier-dev\cli\dist" ( - echo Found dist directory - dir "%NPM_PREFIX%\node_modules\@happier-dev\cli\dist" 2>nul - ) else ( - echo No dist directory found - ) - ) else ( - echo No @happier-dev/cli package found - ) - ) else ( - echo No node_modules directory found - ) - - - name: Test binary execution - shell: cmd - run: | - @echo on - - rem Get npm global prefix and add to PATH - for /f "tokens=*" %%i in ('npm config get prefix') do set NPM_PREFIX=%%i - set PATH=%NPM_PREFIX%;%PATH% - echo NPM_PREFIX: %NPM_PREFIX% - echo Current PATH: %PATH% - - rem Debug: Check if happier exists in various locations - echo Checking for happier binary... - where happier 2>nul && echo Found happier in PATH || echo happier not found in PATH - if exist "%NPM_PREFIX%\happier.cmd" echo Found happier.cmd in NPM_PREFIX - if exist "%NPM_PREFIX%\node_modules\.bin\happier.cmd" echo Found happier.cmd in .bin directory - dir "%NPM_PREFIX%\*happier*" 2>nul || echo No happier files found in NPM_PREFIX - - rem Debug: Check the actual contents and encoding of the installed file - if exist "%NPM_PREFIX%\happier.cmd" ( - echo File size and type: - dir "%NPM_PREFIX%\happier.cmd" - echo. - echo First few lines of the file: - type "%NPM_PREFIX%\happier.cmd" | head -5 - echo. - echo Hex dump of first 50 bytes to check line endings: - powershell -Command "Get-Content '%NPM_PREFIX%\happier.cmd' -Encoding Byte -TotalCount 50 | ForEach-Object { '{0:X2}' -f $_ } | Join-String -Separator ' '" - echo. - echo Testing with direct path... - "%NPM_PREFIX%\happier.cmd" --help - ) else ( - echo Testing happier --help... - happier --help - ) - if errorlevel 1 ( - echo Error: happier --help failed - exit /b 1 - ) - - rem Test version - if exist "%NPM_PREFIX%\happier.cmd" ( - "%NPM_PREFIX%\happier.cmd" --version - ) else ( - happier --version - ) - if errorlevel 1 ( - echo Error: happier --version failed - exit /b 1 - ) - - rem Test doctor - echo Testing happier doctor... - if exist "%NPM_PREFIX%\happier.cmd" ( - "%NPM_PREFIX%\happier.cmd" doctor --help - ) else ( - happier doctor --help - ) - if errorlevel 1 ( - echo Error: happier doctor failed - exit /b 1 - ) - - rem Test daemon help - echo Testing happier daemon --help... - if exist "%NPM_PREFIX%\happier.cmd" ( - "%NPM_PREFIX%\happier.cmd" daemon --help | findstr /C:"Daemon management" >nul - ) else ( - happier daemon --help | findstr /C:"Daemon management" >nul - ) - if errorlevel 1 ( - echo Error: happier daemon --help failed - exit /b 1 - ) - - echo Smoke test passed on Windows! + - name: CLI smoke (pipeline) + run: node scripts/pipeline/run.mjs smoke-cli # We don't need a smoke test for macOS because most contributors are developing on macOS. diff --git a/.github/workflows/deploy-on-deploy-branch.yml b/.github/workflows/deploy-on-deploy-branch.yml new file mode 100644 index 000000000..e37270513 --- /dev/null +++ b/.github/workflows/deploy-on-deploy-branch.yml @@ -0,0 +1,69 @@ +name: DEPLOY — On Deploy Branch Push + +on: + push: + branches: + - "deploy/**" + +permissions: + contents: read + +concurrency: + group: deploy-branch-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + release_actor_guard: + name: Release actor guard + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Authorize release actor + uses: ./.github/actions/release-actor-guard + with: + team_slug: release-admins + trusted_actors: happier-release-bot[bot] + app_id: ${{ secrets.RELEASE_BOT_APP_ID }} + private_key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + + target: + name: Resolve deploy target + needs: [release_actor_guard] + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.parse.outputs.environment }} + component: ${{ steps.parse.outputs.component }} + confirm: ${{ steps.parse.outputs.confirm }} + steps: + - name: Parse deploy branch name + id: parse + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + ref="${REF_NAME}" + if [[ "${ref}" =~ ^deploy/(preview|production)/(ui|server|website|docs)$ ]]; then + env_name="${BASH_REMATCH[1]}" + component="${BASH_REMATCH[2]}" + echo "environment=${env_name}" >> "$GITHUB_OUTPUT" + echo "component=${component}" >> "$GITHUB_OUTPUT" + echo "confirm=deploy ${env_name} ${component}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Unsupported deploy branch ref: ${ref}" >&2 + echo "Expected: deploy//" >&2 + exit 1 + + deploy: + name: Deploy (webhooks) + needs: [target] + uses: ./.github/workflows/deploy.yml + secrets: inherit + with: + environment: ${{ needs.target.outputs.environment }} + component: ${{ needs.target.outputs.component }} + confirm: ${{ needs.target.outputs.confirm }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 71b65a953..5d4abb72c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -68,6 +68,7 @@ jobs: uses: ./.github/actions/release-actor-guard with: team_slug: release-admins + trusted_actors: happier-release-bot[bot] app_id: ${{ secrets.RELEASE_BOT_APP_ID }} private_key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} @@ -109,7 +110,8 @@ jobs: run: | set -euo pipefail - if [ "$EVENT_NAME" != "workflow_dispatch" ] && [ "$EVENT_NAME" != "workflow_call" ]; then + # Called workflows inherit `github.event_name` from the caller (e.g. `push`), so we must accept it here. + if [ "$EVENT_NAME" != "workflow_dispatch" ] && [ "$EVENT_NAME" != "workflow_call" ] && [ "$EVENT_NAME" != "push" ]; then echo "Unsupported event for deploy workflow: $EVENT_NAME" >&2 exit 1 fi @@ -153,6 +155,7 @@ jobs: - name: Trigger deploy webhook(s) env: + GH_TOKEN: ${{ github.token }} CF_WEBHOOK_DEPLOY_CLIENT_ID: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_ID }} CF_WEBHOOK_DEPLOY_CLIENT_SECRET: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_SECRET }} DEPLOY_WEBHOOK_URL: ${{ vars.DEPLOY_WEBHOOK_URL != '' && vars.DEPLOY_WEBHOOK_URL || secrets.DEPLOY_WEBHOOK_URL }} @@ -163,8 +166,8 @@ jobs: HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS || secrets.HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS }} run: | set -euo pipefail - node scripts/pipeline/deploy/trigger-webhooks.mjs \ - --environment "${{ steps.target.outputs.environment }}" \ + node scripts/pipeline/run.mjs deploy \ + --deploy-environment "${{ steps.target.outputs.environment }}" \ --component "${{ steps.target.outputs.component }}" \ --repository "${{ github.repository }}" \ --ref-name "deploy/${{ steps.target.outputs.environment }}/${{ steps.target.outputs.component }}" diff --git a/.github/workflows/extended-db-tests.yml b/.github/workflows/extended-db-tests.yml index c0d5d5980..e8bfe8ee0 100644 --- a/.github/workflows/extended-db-tests.yml +++ b/.github/workflows/extended-db-tests.yml @@ -13,7 +13,7 @@ concurrency: jobs: e2e-postgres: name: Core E2E (Postgres) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 timeout-minutes: 120 services: postgres: @@ -82,7 +82,7 @@ jobs: e2e-mysql: name: Core E2E (MySQL) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 timeout-minutes: 120 services: mysql: diff --git a/.github/workflows/promote-branch.yml b/.github/workflows/promote-branch.yml index 535f51370..61d922289 100644 --- a/.github/workflows/promote-branch.yml +++ b/.github/workflows/promote-branch.yml @@ -170,7 +170,7 @@ jobs: GH_REPO: ${{ github.repository }} run: | set -euo pipefail - node scripts/pipeline/github/promote-branch.mjs \ + node scripts/pipeline/run.mjs promote-branch \ --source "${{ inputs.source }}" \ --target "${{ inputs.target }}" \ --mode "${{ inputs.mode }}" \ diff --git a/.github/workflows/promote-docs.yml b/.github/workflows/promote-docs.yml index 36e2269cb..4a50fee11 100644 --- a/.github/workflows/promote-docs.yml +++ b/.github/workflows/promote-docs.yml @@ -153,6 +153,17 @@ jobs: npm_config_production: "false" run: yarn install --frozen-lockfile --ignore-engines + - name: Resolve deploy branch SHA (before) + id: deploy_before + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + ref="deploy/${{ inputs.environment }}/docs" + sha="$(gh api "repos/${{ github.repository }}/git/ref/heads/${ref}" --jq .object.sha 2>/dev/null || true)" + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + - name: Build docs if: inputs.run_build run: yarn workspace docs build @@ -163,22 +174,42 @@ jobs: GH_REPO: ${{ github.repository }} run: | set -euo pipefail - node scripts/pipeline/github/promote-deploy-branch.mjs \ + node scripts/pipeline/run.mjs promote-deploy-branch \ --deploy-environment "${{ inputs.environment }}" \ --component docs \ --sha "$(git rev-parse HEAD)" \ --summary-file "${GITHUB_STEP_SUMMARY}" - - name: Trigger deploy webhook(s) + - name: Resolve deploy branch SHA (after) + id: deploy_after env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + ref="deploy/${{ inputs.environment }}/docs" + sha="$(gh api "repos/${{ github.repository }}/git/ref/heads/${ref}" --jq .object.sha 2>/dev/null || true)" + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + + - name: Trigger deploy webhook(s) (force_deploy, no branch change) + if: inputs.force_deploy && steps.deploy_before.outputs.sha == steps.deploy_after.outputs.sha + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} CF_WEBHOOK_DEPLOY_CLIENT_ID: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_ID }} CF_WEBHOOK_DEPLOY_CLIENT_SECRET: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_SECRET }} DEPLOY_WEBHOOK_URL: ${{ vars.DEPLOY_WEBHOOK_URL != '' && vars.DEPLOY_WEBHOOK_URL || secrets.DEPLOY_WEBHOOK_URL }} HAPPIER_DOCS_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_DOCS_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_DOCS_DEPLOY_WEBHOOKS || secrets.HAPPIER_DOCS_DEPLOY_WEBHOOKS }} run: | set -euo pipefail - node scripts/pipeline/deploy/trigger-webhooks.mjs \ - --environment "${{ inputs.environment }}" \ + node scripts/pipeline/run.mjs deploy \ + --deploy-environment "${{ inputs.environment }}" \ --component "docs" \ --repository "${{ github.repository }}" \ --ref-name "deploy/${{ inputs.environment }}/docs" + + - name: Deploy note + run: | + set -euo pipefail + echo "Deployment is triggered automatically by pushes to deploy/${{ inputs.environment }}/docs." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- deploy_branch_sha: \`${{ steps.deploy_after.outputs.sha }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/promote-server.yml b/.github/workflows/promote-server.yml index ea9adc626..1d24fe75a 100644 --- a/.github/workflows/promote-server.yml +++ b/.github/workflows/promote-server.yml @@ -222,21 +222,17 @@ jobs: if: inputs.run_tests run: yarn --cwd apps/server test - - name: Commit version bump (source ref) - if: steps.bump_server.outputs.skipped != 'true' - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add apps/server/package.json packages/relay-server/package.json - git commit -m "chore(release): bump server to v${{ steps.bump_server.outputs.version }}" - - - name: Push source ref (only for branches) + - name: Commit + push version bump (pipeline) if: steps.bump_server.outputs.skipped != 'true' run: | set -euo pipefail src="${{ steps.resolve.outputs.ref }}" - git push origin "HEAD:refs/heads/$src" + node scripts/pipeline/run.mjs github-commit-and-push \ + --paths "apps/server/package.json,packages/relay-server/package.json" \ + --allow-missing false \ + --message "chore(release): bump server to v${{ steps.bump_server.outputs.version }}" \ + --push-ref "${src}" \ + --push-mode auto - name: Bootstrap minisign if: inputs.publish_runtime_release == true @@ -312,7 +308,7 @@ jobs: title="Happier Server Stable" prerelease="false" fi - node scripts/pipeline/github/publish-release.mjs \ + node scripts/pipeline/run.mjs github-publish-release \ --tag "${tag}" \ --title "${title}" \ --target-sha "$(git rev-parse HEAD)" \ @@ -334,7 +330,7 @@ jobs: version="$(node -p 'require("./apps/server/package.json").version' | tr -d '\n')" tag="server-v${version}" title="Happier Server v${version}" - node scripts/pipeline/github/publish-release.mjs \ + node scripts/pipeline/run.mjs github-publish-release \ --tag "${tag}" \ --title "${title}" \ --target-sha "$(git rev-parse HEAD)" \ @@ -344,20 +340,44 @@ jobs: --assets-dir "dist/release-assets/server" \ --clobber "true" + - name: Resolve deploy branch SHA (before) + id: deploy_before + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + ref="deploy/${{ inputs.environment }}/server" + sha="$(gh api "repos/${{ github.repository }}/git/ref/heads/${ref}" --jq .object.sha 2>/dev/null || true)" + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + - name: Promote source ref to deploy branch env: GH_TOKEN: ${{ steps.app_token.outputs.token }} GH_REPO: ${{ github.repository }} run: | set -euo pipefail - node scripts/pipeline/github/promote-deploy-branch.mjs \ + node scripts/pipeline/run.mjs promote-deploy-branch \ --deploy-environment "${{ inputs.environment }}" \ --component server \ --sha "$(git rev-parse HEAD)" \ --summary-file "${GITHUB_STEP_SUMMARY}" - - name: Trigger deploy webhook(s) + - name: Resolve deploy branch SHA (after) + id: deploy_after env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + ref="deploy/${{ inputs.environment }}/server" + sha="$(gh api "repos/${{ github.repository }}/git/ref/heads/${ref}" --jq .object.sha 2>/dev/null || true)" + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + + - name: Trigger deploy webhook(s) (force_deploy, no branch change) + if: inputs.force_deploy && steps.deploy_before.outputs.sha == steps.deploy_after.outputs.sha + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} CF_WEBHOOK_DEPLOY_CLIENT_ID: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_ID }} CF_WEBHOOK_DEPLOY_CLIENT_SECRET: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_SECRET }} DEPLOY_WEBHOOK_URL: ${{ vars.DEPLOY_WEBHOOK_URL != '' && vars.DEPLOY_WEBHOOK_URL || secrets.DEPLOY_WEBHOOK_URL }} @@ -365,8 +385,15 @@ jobs: HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS || secrets.HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS }} run: | set -euo pipefail - node scripts/pipeline/deploy/trigger-webhooks.mjs \ - --environment "${{ inputs.environment }}" \ + node scripts/pipeline/run.mjs deploy \ + --deploy-environment "${{ inputs.environment }}" \ --component "server" \ --repository "${{ github.repository }}" \ --ref-name "deploy/${{ inputs.environment }}/server" + + - name: Deploy note + run: | + set -euo pipefail + echo "Deployment is triggered automatically by pushes to deploy/${{ inputs.environment }}/server." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- deploy_branch_sha: \`${{ steps.deploy_after.outputs.sha }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/promote-ui.yml b/.github/workflows/promote-ui.yml index c29cf1b82..bce52ec83 100644 --- a/.github/workflows/promote-ui.yml +++ b/.github/workflows/promote-ui.yml @@ -303,28 +303,17 @@ jobs: yarn --cwd apps/ui test:unit yarn --cwd apps/ui test:integration - - name: Commit version bump (source ref) - if: steps.bump_app.outputs.skipped != 'true' - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add apps/ui/package.json apps/ui/app.config.js - if [ -d apps/ui/src-tauri ]; then - git add apps/ui/src-tauri || true - fi - git commit -m "chore(release): bump app to v${{ steps.bump_app.outputs.version }}" - - - name: Push source ref (only for branches) + - name: Commit + push version bump (pipeline) if: steps.bump_app.outputs.skipped != 'true' run: | set -euo pipefail src="${{ steps.resolve.outputs.ref }}" - if git show-ref --verify --quiet "refs/remotes/origin/$src"; then - git push origin "HEAD:refs/heads/$src" - else - echo "Skipping push: '$src' is not a known branch ref." - fi + node scripts/pipeline/run.mjs github-commit-and-push \ + --paths "apps/ui/package.json,apps/ui/app.config.js,apps/ui/src-tauri" \ + --allow-missing true \ + --message "chore(release): bump app to v${{ steps.bump_app.outputs.version }}" \ + --push-ref "${src}" \ + --push-mode auto - name: Capture release SHA id: release_sha @@ -338,7 +327,7 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: | set -euo pipefail - node scripts/pipeline/expo/ota-update.mjs \ + node scripts/pipeline/run.mjs expo-ota \ --environment "${{ inputs.environment }}" \ --message "${{ inputs.expo_update_message }}" \ --eas-cli-version "${EAS_CLI_VERSION}" @@ -386,6 +375,18 @@ jobs: --release-message "${{ inputs.expo_update_message }}" \ --secrets-source env + - name: Resolve deploy branch SHA (before) + if: inputs.deploy_web + id: deploy_before + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + ref="deploy/${{ inputs.environment }}/ui" + sha="$(gh api "repos/${{ github.repository }}/git/ref/heads/${ref}" --jq .object.sha 2>/dev/null || true)" + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + - name: Promote source ref to deploy branch (web) if: inputs.deploy_web env: @@ -393,27 +394,48 @@ jobs: GH_REPO: ${{ github.repository }} run: | set -euo pipefail - node scripts/pipeline/github/promote-deploy-branch.mjs \ + node scripts/pipeline/run.mjs promote-deploy-branch \ --deploy-environment "${{ inputs.environment }}" \ --component ui \ --sha "$(git rev-parse HEAD)" \ --summary-file "${GITHUB_STEP_SUMMARY}" - - name: Trigger deploy webhook(s) (web) + - name: Resolve deploy branch SHA (after) if: inputs.deploy_web + id: deploy_after env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + ref="deploy/${{ inputs.environment }}/ui" + sha="$(gh api "repos/${{ github.repository }}/git/ref/heads/${ref}" --jq .object.sha 2>/dev/null || true)" + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + + - name: Trigger deploy webhook(s) (web) (force_deploy, no branch change) + if: inputs.deploy_web && inputs.force_deploy && steps.deploy_before.outputs.sha == steps.deploy_after.outputs.sha + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} CF_WEBHOOK_DEPLOY_CLIENT_ID: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_ID }} CF_WEBHOOK_DEPLOY_CLIENT_SECRET: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_SECRET }} DEPLOY_WEBHOOK_URL: ${{ vars.DEPLOY_WEBHOOK_URL != '' && vars.DEPLOY_WEBHOOK_URL || secrets.DEPLOY_WEBHOOK_URL }} HAPPIER_UI_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_UI_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_UI_DEPLOY_WEBHOOKS || secrets.HAPPIER_UI_DEPLOY_WEBHOOKS }} run: | set -euo pipefail - node scripts/pipeline/deploy/trigger-webhooks.mjs \ - --environment "${{ inputs.environment }}" \ + node scripts/pipeline/run.mjs deploy \ + --deploy-environment "${{ inputs.environment }}" \ --component "ui" \ --repository "${{ github.repository }}" \ --ref-name "deploy/${{ inputs.environment }}/ui" + - name: Deploy note (web) + if: inputs.deploy_web + run: | + set -euo pipefail + echo "Web deployment is triggered automatically by pushes to deploy/${{ inputs.environment }}/ui." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- deploy_branch_sha: \`${{ steps.deploy_after.outputs.sha }}\`" >> "$GITHUB_STEP_SUMMARY" + expo_local_build: name: Expo native build (local runners) if: ${{ (inputs.expo_action == 'native' || inputs.expo_action == 'native_submit') && inputs.expo_builder == 'eas_local' }} diff --git a/.github/workflows/promote-website.yml b/.github/workflows/promote-website.yml index 848ac2b31..443ff976d 100644 --- a/.github/workflows/promote-website.yml +++ b/.github/workflows/promote-website.yml @@ -222,29 +222,6 @@ jobs: if: steps.bump_website.outputs.skipped != 'true' || steps.sync_installers.outputs.changed == 'true' run: | set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add \ - apps/website/package.json \ - apps/website/public/install \ - apps/website/public/install.sh \ - apps/website/public/install-preview \ - apps/website/public/install-preview.sh \ - apps/website/public/self-host.sh \ - apps/website/public/self-host \ - apps/website/public/self-host-preview \ - apps/website/public/self-host-preview.sh \ - apps/website/public/install.ps1 \ - apps/website/public/install-preview.ps1 \ - apps/website/public/self-host.ps1 \ - apps/website/public/self-host-preview.ps1 \ - apps/website/public/happier-release.pub - - if git diff --cached --quiet; then - echo "did_commit=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "${{ steps.bump_website.outputs.skipped }}" != "true" ]; then message="chore(release): bump website to v${{ steps.bump_website.outputs.version }}" if [ "${{ steps.sync_installers.outputs.changed }}" = "true" ]; then @@ -254,26 +231,31 @@ jobs: message="chore(release): sync website installers from release sources" fi - git commit -m "${message}" - echo "did_commit=true" >> "$GITHUB_OUTPUT" - - - name: Detect whether source ref is a branch - id: source_ref_kind - run: | - set -euo pipefail src="${{ steps.resolve.outputs.ref }}" - if git ls-remote --exit-code --heads origin "${src}" >/dev/null 2>&1; then - echo "is_branch=true" >> "$GITHUB_OUTPUT" + paths="apps/website/package.json,apps/website/public/install,apps/website/public/install.sh,apps/website/public/install-preview,apps/website/public/install-preview.sh,apps/website/public/self-host.sh,apps/website/public/self-host,apps/website/public/self-host-preview,apps/website/public/self-host-preview.sh,apps/website/public/install.ps1,apps/website/public/install-preview.ps1,apps/website/public/self-host.ps1,apps/website/public/self-host-preview.ps1,apps/website/public/happier-release.pub" + out="$(node scripts/pipeline/run.mjs github-commit-and-push \ + --paths "${paths}" \ + --allow-missing false \ + --message "${message}" \ + --push-ref "${src}" \ + --push-mode auto)" + + if echo "${out}" | grep -q "DID_COMMIT=true"; then + echo "did_commit=true" >> "$GITHUB_OUTPUT" else - echo "is_branch=false" >> "$GITHUB_OUTPUT" + echo "did_commit=false" >> "$GITHUB_OUTPUT" fi - - name: Push source ref (only for branches) - if: steps.commit_changes.outputs.did_commit == 'true' && steps.source_ref_kind.outputs.is_branch == 'true' + - name: Resolve deploy branch SHA (before) + id: deploy_before + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_REPO: ${{ github.repository }} run: | set -euo pipefail - src="${{ steps.resolve.outputs.ref }}" - git push origin "HEAD:refs/heads/$src" + ref="deploy/${{ inputs.environment }}/website" + sha="$(gh api "repos/${{ github.repository }}/git/ref/heads/${ref}" --jq .object.sha 2>/dev/null || true)" + echo "sha=${sha}" >> "$GITHUB_OUTPUT" - name: Promote source ref to deploy branch env: @@ -281,22 +263,42 @@ jobs: GH_REPO: ${{ github.repository }} run: | set -euo pipefail - node scripts/pipeline/github/promote-deploy-branch.mjs \ + node scripts/pipeline/run.mjs promote-deploy-branch \ --deploy-environment "${{ inputs.environment }}" \ --component website \ --sha "$(git rev-parse HEAD)" \ --summary-file "${GITHUB_STEP_SUMMARY}" - - name: Trigger deploy webhook(s) + - name: Resolve deploy branch SHA (after) + id: deploy_after + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + ref="deploy/${{ inputs.environment }}/website" + sha="$(gh api "repos/${{ github.repository }}/git/ref/heads/${ref}" --jq .object.sha 2>/dev/null || true)" + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + + - name: Trigger deploy webhook(s) (force_deploy, no branch change) + if: inputs.force_deploy && steps.deploy_before.outputs.sha == steps.deploy_after.outputs.sha env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} CF_WEBHOOK_DEPLOY_CLIENT_ID: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_ID }} CF_WEBHOOK_DEPLOY_CLIENT_SECRET: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_SECRET }} DEPLOY_WEBHOOK_URL: ${{ vars.DEPLOY_WEBHOOK_URL != '' && vars.DEPLOY_WEBHOOK_URL || secrets.DEPLOY_WEBHOOK_URL }} HAPPIER_WEBSITE_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_WEBSITE_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_WEBSITE_DEPLOY_WEBHOOKS || secrets.HAPPIER_WEBSITE_DEPLOY_WEBHOOKS }} run: | set -euo pipefail - node scripts/pipeline/deploy/trigger-webhooks.mjs \ - --environment "${{ inputs.environment }}" \ + node scripts/pipeline/run.mjs deploy \ + --deploy-environment "${{ inputs.environment }}" \ --component "website" \ --repository "${{ github.repository }}" \ --ref-name "deploy/${{ inputs.environment }}/website" + + - name: Deploy note + run: | + set -euo pipefail + echo "Deployment is triggered automatically by pushes to deploy/${{ inputs.environment }}/website." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- deploy_branch_sha: \`${{ steps.deploy_after.outputs.sha }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 484eb478c..57316c8e9 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -21,13 +21,18 @@ on: required: true default: true type: boolean + registries: + description: "Comma-separated registries to push to (dockerhub,ghcr)" + required: true + default: "dockerhub,ghcr" + type: string build_relay: description: "Build/push relay-server image" required: true default: true type: boolean - build_devcontainer: - description: "Build/push devcontainer image" + build_dev_box: + description: "Build/push dev-box image" required: true default: true type: boolean @@ -44,17 +49,22 @@ on: required: false default: true type: boolean + registries: + required: false + default: dockerhub,ghcr + type: string build_relay: required: false default: true type: boolean - build_devcontainer: + build_dev_box: required: false default: true type: boolean permissions: contents: read + packages: write jobs: release_actor_guard: @@ -122,11 +132,45 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build & push images (pipeline) + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + GHCR_NAMESPACE: ghcr.io/happier-dev + GHCR_USERNAME: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - node scripts/pipeline/docker/publish-images.mjs \ + node scripts/pipeline/run.mjs docker-publish \ --channel "${{ inputs.channel }}" \ + --registries "${{ inputs.registries }}" \ --push-latest "${{ inputs.push_latest }}" \ --build-relay "${{ inputs.build_relay }}" \ - --build-devcontainer "${{ inputs.build_devcontainer }}" + --build-dev-box "${{ inputs.build_dev_box }}" + + - name: Update Docker Hub README (relay-server) + if: ${{ inputs.build_relay }} + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: happierdev/relay-server + readme-filepath: docker/dockerhub/relay-server.md + short-description: Self-host the Happier Server (Docker image) + + - name: Update Docker Hub README (dev-box) + if: ${{ inputs.build_dev_box }} + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: happierdev/dev-box + readme-filepath: docker/dockerhub/dev-box.md + short-description: Happier dev box (CLI + daemon in Docker) diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 0f70f3911..b2420f511 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -131,7 +131,7 @@ jobs: release_message: ${{ inputs.release_message }} run: | set -euo pipefail - node scripts/pipeline/github/publish-release.mjs \ + node scripts/pipeline/run.mjs github-publish-release \ --tag "${{ inputs.tag }}" \ --title "${{ inputs.title }}" \ --target-sha "${{ inputs.target_sha }}" \ diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 4a37210a6..5d56f9743 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -102,7 +102,8 @@ jobs: name: Release actor guard permissions: contents: read - runs-on: ubuntu-latest + # We install Sapling via a Ubuntu 22.04 package; pin runner for compatibility. + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v4 @@ -118,7 +119,7 @@ jobs: permissions: contents: write id-token: write - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest environment: release-shared env: HAPPIER_EMBEDDED_POLICY_ENV: ${{ inputs.channel == 'production' && 'production' || 'preview' }} @@ -254,18 +255,6 @@ jobs: fi echo "dir=${dir}" >> "$GITHUB_OUTPUT" - - name: Set preview versions (working tree only) - if: inputs.channel == 'preview' - run: | - set -euo pipefail - node scripts/pipeline/npm/set-preview-versions.mjs \ - --publish-cli "${{ inputs.publish_cli }}" \ - --publish-stack "${{ inputs.publish_stack }}" \ - --publish-server "${{ inputs.publish_server }}" \ - --server-runner-dir "${SERVER_RUNNER_DIR}" - env: - SERVER_RUNNER_DIR: ${{ steps.server_runner.outputs.dir }} - - name: Install Sapling if: inputs.publish_cli && inputs.run_tests run: | @@ -308,22 +297,44 @@ jobs: echo "npm_tag=latest" >> "$GITHUB_OUTPUT" fi + versions_json="{}" + if [ "${INPUT_CHANNEL}" = "preview" ]; then + versions_json="$(node scripts/pipeline/run.mjs npm-set-preview-versions \ + --publish-cli "${INPUT_PUBLISH_CLI}" \ + --publish-stack "${INPUT_PUBLISH_STACK}" \ + --publish-server "${INPUT_PUBLISH_SERVER}" \ + --server-runner-dir "${SERVER_RUNNER_DIR}" \ + --write false)" + fi + if [ "${INPUT_PUBLISH_CLI}" = "true" ]; then - cli_version="$(node -p 'require("./apps/cli/package.json").version' | tr -d '\n')" + if [ "${INPUT_CHANNEL}" = "preview" ]; then + cli_version="$(node -e 'const raw=process.argv[1]; const v=JSON.parse(raw); process.stdout.write(String(v.cli||\"\"));' "${versions_json}")" + else + cli_version="$(node -p 'require("./apps/cli/package.json").version' | tr -d '\n')" + fi echo "cli_version=${cli_version}" >> "$GITHUB_OUTPUT" else echo "cli_version=SKIP" >> "$GITHUB_OUTPUT" fi if [ "${INPUT_PUBLISH_STACK}" = "true" ]; then - stack_version="$(node -p 'require("./apps/stack/package.json").version' | tr -d '\n')" + if [ "${INPUT_CHANNEL}" = "preview" ]; then + stack_version="$(node -e 'const raw=process.argv[1]; const v=JSON.parse(raw); process.stdout.write(String(v.stack||\"\"));' "${versions_json}")" + else + stack_version="$(node -p 'require("./apps/stack/package.json").version' | tr -d '\n')" + fi echo "stack_version=${stack_version}" >> "$GITHUB_OUTPUT" else echo "stack_version=SKIP" >> "$GITHUB_OUTPUT" fi if [ "${INPUT_PUBLISH_SERVER}" = "true" ]; then - server_version="$(node -p 'require("./" + process.env.SERVER_RUNNER_DIR + "/package.json").version' | tr -d '\n')" + if [ "${INPUT_CHANNEL}" = "preview" ]; then + server_version="$(node -e 'const raw=process.argv[1]; const v=JSON.parse(raw); process.stdout.write(String(v.server||\"\"));' "${versions_json}")" + else + server_version="$(node -p 'require("./" + process.env.SERVER_RUNNER_DIR + "/package.json").version' | tr -d '\n')" + fi echo "server_version=${server_version}" >> "$GITHUB_OUTPUT" else echo "server_version=SKIP" >> "$GITHUB_OUTPUT" @@ -388,7 +399,7 @@ jobs: SERVER_RUNNER_DIR: ${{ steps.server_runner.outputs.dir }} run: | set -euo pipefail - node scripts/pipeline/npm/release-packages.mjs \ + node scripts/pipeline/run.mjs npm-release \ --channel "${{ inputs.channel }}" \ --publish-cli "${{ inputs.publish_cli }}" \ --publish-stack "${{ inputs.publish_stack }}" \ @@ -545,7 +556,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail - node scripts/pipeline/npm/publish-tarball.mjs \ + node scripts/pipeline/run.mjs npm-publish \ --channel "${{ inputs.channel }}" \ --tarball-dir "dist/release-assets/cli" @@ -579,7 +590,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail - node scripts/pipeline/npm/publish-tarball.mjs \ + node scripts/pipeline/run.mjs npm-publish \ --channel "${{ inputs.channel }}" \ --tarball-dir "dist/release-assets/stack" @@ -613,7 +624,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail - node scripts/pipeline/npm/publish-tarball.mjs \ + node scripts/pipeline/run.mjs npm-publish \ --channel "${{ inputs.channel }}" \ --tarball-dir "dist/release-assets/server" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87452fc16..829847bfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -360,34 +360,7 @@ jobs: - name: CLI smoke test (Linux) if: ${{ (inputs.checks_profile == 'full') || (inputs.checks_profile == 'custom' && contains(format(',{0},', inputs.custom_checks), ',cli_smoke_linux,')) }} - run: | - set -euo pipefail - yarn workspace @happier-dev/cli build - PACKAGE_NAME="$(cd apps/cli && npm pack --silent --pack-destination /tmp)" - PACKAGE_FILE="/tmp/${PACKAGE_NAME}" - if [ ! -f "${PACKAGE_FILE}" ]; then - echo "Unable to find packed CLI tarball at ${PACKAGE_FILE}" >&2 - exit 1 - fi - - SMOKE_PREFIX="$(mktemp -d)" - SMOKE_HOME="$(mktemp -d)" - npm install -g --prefix "${SMOKE_PREFIX}" "${PACKAGE_FILE}" - - HAPPIER_BIN="${SMOKE_PREFIX}/bin/happier" - if [ ! -x "${HAPPIER_BIN}" ]; then - echo "Expected CLI binary not found at ${HAPPIER_BIN}" >&2 - exit 1 - fi - - HAPPIER_HOME_DIR="${SMOKE_HOME}" timeout 30s "${HAPPIER_BIN}" --help - HAPPIER_HOME_DIR="${SMOKE_HOME}" timeout 10s "${HAPPIER_BIN}" --version - DOCTOR_OUTPUT="$(HAPPIER_HOME_DIR="${SMOKE_HOME}" timeout 10s "${HAPPIER_BIN}" doctor --help)" - printf '%s\n' "${DOCTOR_OUTPUT}" - - DAEMON_HELP_OUTPUT="$(HAPPIER_HOME_DIR="${SMOKE_HOME}" timeout 10s "${HAPPIER_BIN}" daemon --help)" - printf '%s\n' "${DAEMON_HELP_OUTPUT}" - printf '%s' "${DAEMON_HELP_OUTPUT}" | grep -q 'Daemon management' + run: node scripts/pipeline/run.mjs smoke-cli - name: Compute changed components (main..dev) id: plan @@ -500,6 +473,11 @@ jobs: fetch-depth: 0 token: ${{ steps.app_token.outputs.token }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22.x + - name: Compute deploy plan (deploy//* behind release source?) id: plan env: @@ -667,7 +645,7 @@ jobs: source_ref: dev push_latest: true build_relay: ${{ inputs.force_deploy == true || needs.plan.outputs.changed_ui == 'true' || needs.plan.outputs.changed_server == 'true' || needs.plan.outputs.changed_shared == 'true' }} - build_devcontainer: ${{ inputs.force_deploy == true || needs.plan.outputs.changed_cli == 'true' || needs.plan.outputs.changed_stack == 'true' || needs.plan.outputs.changed_shared == 'true' }} + build_dev_box: ${{ inputs.force_deploy == true || needs.plan.outputs.changed_cli == 'true' || needs.plan.outputs.changed_stack == 'true' || needs.plan.outputs.changed_shared == 'true' }} deploy_website: if: always() && needs.plan.result == 'success' && inputs.dry_run != true && contains(format(',{0},', inputs.deploy_targets), ',website,') && (needs.deploy_plan.outputs.deploy_website_needed == 'true' || needs.plan.outputs.bump_website != 'none' || inputs.force_deploy == true) && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped') && (inputs.environment != 'production' || needs.promote_main.result == 'success') diff --git a/.github/workflows/roadmap-add-to-project.yml b/.github/workflows/roadmap-add-to-project.yml new file mode 100644 index 000000000..ef7f5cfa0 --- /dev/null +++ b/.github/workflows/roadmap-add-to-project.yml @@ -0,0 +1,211 @@ +name: "Roadmap: Add to Project" + +on: + issues: + types: [opened, reopened, transferred, labeled, unlabeled] + workflow_dispatch: + inputs: + issue_number: + description: "Issue number to resync" + required: true + +jobs: + add-to-project: + name: Sync roadmap fields + runs-on: ubuntu-latest + environment: roadmap + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.issue != null && contains(github.event.issue.labels.*.name, 'roadmap')) + permissions: + contents: read + + steps: + - name: Create GitHub App token + id: app_token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.ROADMAP_BOT_APP_ID || secrets.ROADMAP_BOT_APP_ID }} + private-key: ${{ secrets.ROADMAP_BOT_PRIVATE_KEY }} + + - name: Sync roadmap labels to project fields + uses: actions/github-script@v7 + env: + PROJECT_ORG: happier-dev + PROJECT_NUMBER: "1" + PROJECT_PRIORITY_FIELD: Priority + PROJECT_RELEASE_STAGE_FIELD: Release stage + PROJECT_STATUS_FIELD: Status + with: + github-token: ${{ steps.app_token.outputs.token }} + script: | + const { owner, repo } = context.repo; + + let issue = context.payload.issue ?? null; + if (!issue && context.eventName === 'workflow_dispatch') { + const raw = String(context.payload.inputs?.issue_number ?? '').trim(); + const issueNumber = Number.parseInt(raw, 10); + if (!Number.isFinite(issueNumber) || issueNumber <= 0) { + throw new Error(`Invalid workflow_dispatch input: issue_number='${raw}'`); + } + + const res = await github.rest.issues.get({ owner, repo, issue_number: issueNumber }); + issue = res?.data ?? null; + } + + const contentNodeId = issue?.node_id ?? null; + if (!contentNodeId) { + core.info('Missing issue/PR node id in event payload; skipping.'); + return; + } + + const labels = (issue?.labels ?? []) + .map((l) => (typeof l === 'string' ? l : String(l?.name ?? ''))) + .map((n) => n.trim()) + .filter(Boolean); + + const priorityLabel = labels.find((name) => name.toLowerCase().startsWith('priority:')); + const stageLabel = labels.find((name) => name.toLowerCase().startsWith('stage:')); + + const priorityOptionName = priorityLabel + ? String(priorityLabel.split(':')[1] ?? '').trim().toUpperCase() + : null; + const stageKey = stageLabel ? String(stageLabel.split(':')[1] ?? '').trim().toLowerCase() : null; + const stageOptionName = + stageKey === 'ga' + ? 'GA' + : stageKey === 'beta' + ? 'Beta' + : stageKey === 'experimental' + ? 'Experimental' + : stageKey === 'not-shipped' + ? 'Not shipped' + : null; + + if (!priorityOptionName && !stageOptionName) { + core.info('No priority:* or stage:* labels found; skipping field sync.'); + return; + } + + const org = String(process.env.PROJECT_ORG ?? '').trim(); + const number = Number.parseInt(String(process.env.PROJECT_NUMBER ?? '0'), 10); + if (!org || !Number.isFinite(number) || number <= 0) { + throw new Error('Invalid project org/number configuration.'); + } + + const priorityFieldName = String(process.env.PROJECT_PRIORITY_FIELD ?? 'Priority'); + const stageFieldName = String(process.env.PROJECT_RELEASE_STAGE_FIELD ?? 'Release stage'); + + const project = await github.graphql( + ` + query ProjectFields($org: String!, $number: Int!) { + organization(login: $org) { + projectV2(number: $number) { + id + fields(first: 100) { + nodes { + __typename + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + } + } + `, + { org, number }, + ); + + const projectV2 = project?.organization?.projectV2; + if (!projectV2?.id) throw new Error('Failed to resolve project id.'); + + const singleSelectFields = (projectV2.fields?.nodes ?? []).filter((n) => n?.__typename === 'ProjectV2SingleSelectField'); + const findField = (name) => singleSelectFields.find((f) => String(f?.name ?? '').trim() === name); + const priorityField = findField(priorityFieldName); + const stageField = findField(stageFieldName); + + async function findProjectItemIdForContent() { + let cursor = null; + // Reasonable cap: curated roadmap project shouldn't be massive; still paginate to be safe. + for (let page = 0; page < 20; page++) { + const res = await github.graphql( + ` + query ProjectItems($org: String!, $number: Int!, $after: String) { + organization(login: $org) { + projectV2(number: $number) { + items(first: 50, after: $after) { + nodes { + id + content { + __typename + ... on Issue { id } + ... on PullRequest { id } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + } + `, + { org, number, after: cursor }, + ); + + const items = res?.organization?.projectV2?.items; + const nodes = Array.isArray(items?.nodes) ? items.nodes : []; + const match = nodes.find((n) => String(n?.content?.id ?? '') === String(contentNodeId)); + if (match?.id) return String(match.id); + + if (!items?.pageInfo?.hasNextPage) return null; + cursor = items.pageInfo.endCursor ?? null; + if (!cursor) return null; + } + return null; + } + + let itemId = null; + for (let attempt = 1; attempt <= 10; attempt++) { + itemId = await findProjectItemIdForContent(); + if (itemId) break; + core.info(`Project item not found yet (attempt ${attempt}/10); waiting...`); + await new Promise((r) => setTimeout(r, 3000)); + } + + if (!itemId) { + core.info('Project item not found; ensure Project auto-add is enabled for label:roadmap.'); + return; + } + + async function setSingleSelect(field, optionName) { + if (!field?.id) return false; + const options = Array.isArray(field.options) ? field.options : []; + const option = options.find((o) => String(o?.name ?? '').trim() === optionName); + if (!option?.id) return false; + await github.graphql( + ` + mutation SetField($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + `, + { projectId: projectV2.id, itemId, fieldId: field.id, optionId: option.id }, + ); + return true; + } + + if (priorityOptionName) { + const ok = await setSingleSelect(priorityField, priorityOptionName); + core.info(ok ? `Set ${priorityFieldName}=${priorityOptionName}` : `Skipped ${priorityFieldName} (field/option not found)`); + } + + if (stageOptionName) { + const ok = await setSingleSelect(stageField, stageOptionName); + core.info(ok ? `Set ${stageFieldName}=${stageOptionName}` : `Skipped ${stageFieldName} (field/option not found)`); + } diff --git a/.github/workflows/roadmap-bootstrap-labels.yml b/.github/workflows/roadmap-bootstrap-labels.yml new file mode 100644 index 000000000..855388b2b --- /dev/null +++ b/.github/workflows/roadmap-bootstrap-labels.yml @@ -0,0 +1,82 @@ +name: "Roadmap: Bootstrap Labels" + +on: + workflow_dispatch: + inputs: + force_update: + description: Update label descriptions/colors if they already exist. + required: false + type: boolean + default: false + +jobs: + bootstrap: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Validate actor permissions + uses: actions/github-script@v7 + with: + script: | + const actor = context.actor; + const { owner, repo } = context.repo; + const result = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: actor }); + const allowed = new Set(['admin', 'maintain', 'write', 'triage']); + if (!allowed.has(result.data.permission)) { + core.setFailed(`Actor '${actor}' does not have permission to bootstrap labels.`); + } + + - name: Create/update roadmap labels + uses: actions/github-script@v7 + with: + script: | + const force = Boolean(${{ inputs.force_update }}); + const { owner, repo } = context.repo; + + /** @type {Array<{name: string, color: string, description: string}>} */ + const labels = [ + { name: 'roadmap', color: '5319e7', description: 'Include on the public roadmap project.' }, + + { name: 'priority:p0', color: 'b60205', description: 'Drop-everything priority.' }, + { name: 'priority:p1', color: 'd93f0b', description: 'High priority.' }, + { name: 'priority:p2', color: 'fbca04', description: 'Normal priority.' }, + { name: 'priority:p3', color: 'c2e0c6', description: 'Low priority.' }, + + { name: 'stage:not-shipped', color: 'bfdadc', description: 'Not shipped yet.' }, + { name: 'stage:experimental', color: '1d76db', description: 'Shipped behind experimental / preview.' }, + { name: 'stage:beta', color: '0e8a16', description: 'Shipped to beta users.' }, + { name: 'stage:ga', color: '0052cc', description: 'Generally available.' }, + + { name: 'type: bug', color: 'd73a4a', description: 'Bug or regression.' }, + { name: 'type: feature', color: 'a2eeef', description: 'New feature or enhancement.' }, + { name: 'type: task', color: 'ededed', description: 'Internal task or chore.' }, + + { name: 'source: bug-report', color: 'fef2c0', description: 'Created from in-app bug report.' }, + { name: 'ai-triage', color: 'cfd3d7', description: 'Needs maintainer triage.' }, + ]; + + for (const label of labels) { + try { + await github.rest.issues.createLabel({ owner, repo, name: label.name, color: label.color, description: label.description }); + core.info(`Created label: ${label.name}`); + } catch (error) { + const status = error?.status; + // 422: already exists (or validation); treat as "exists" for our purposes. + if (status !== 422) throw error; + if (!force) { + core.info(`Label exists (skipped): ${label.name}`); + continue; + } + await github.rest.issues.updateLabel({ + owner, + repo, + name: label.name, + new_name: label.name, + color: label.color, + description: label.description, + }); + core.info(`Updated label: ${label.name}`); + } + } diff --git a/.github/workflows/tests-dispatch.yml b/.github/workflows/tests-dispatch.yml index 62749eb70..bad954a6d 100644 --- a/.github/workflows/tests-dispatch.yml +++ b/.github/workflows/tests-dispatch.yml @@ -13,7 +13,7 @@ on: - fast - custom custom_checks: - description: "Custom — Comma-separated checks used only when profile=custom (ui,server,cli,stack,typecheck,daemon_e2e,e2e_core,e2e_core_slow,server_db_contract,providers,stress,release_contracts,installers_smoke,binary_smoke,self_host_systemd,self_host_launchd,self_host_schtasks,self_host_daemon)" + description: "Custom — Comma-separated checks used only when profile=custom (ui,ui_e2e,server,cli,stack,typecheck,daemon_e2e,e2e_core,e2e_core_slow,server_db_contract,providers,stress,release_contracts,installers_smoke,binary_smoke,self_host_systemd,self_host_launchd,self_host_schtasks,self_host_daemon)" required: true default: "" type: string @@ -49,6 +49,7 @@ jobs: name: Resolve flags runs-on: ubuntu-latest outputs: + run_ui_e2e: ${{ steps.flags.outputs.run_ui_e2e }} run_ui: ${{ steps.flags.outputs.run_ui }} run_server: ${{ steps.flags.outputs.run_server }} run_cli: ${{ steps.flags.outputs.run_cli }} @@ -89,6 +90,7 @@ jobs: fi if [ "${profile}" = "full" ]; then + run_ui_e2e=true run_ui=true run_server=true run_cli=true @@ -108,6 +110,7 @@ jobs: run_self_host_schtasks=false run_self_host_daemon=false elif [ "${profile}" = "fast" ]; then + run_ui_e2e=true run_ui=true run_server=true run_cli=true @@ -127,6 +130,7 @@ jobs: run_self_host_schtasks=false run_self_host_daemon=false else + run_ui_e2e=false run_ui=false run_server=false run_cli=false @@ -147,6 +151,7 @@ jobs: run_self_host_daemon=false if has ui; then run_ui=true; fi + if has ui_e2e; then run_ui_e2e=true; fi if has server; then run_server=true; fi if has cli; then run_cli=true; fi if has stack; then run_stack=true; fi @@ -167,6 +172,7 @@ jobs: fi { + echo "run_ui_e2e=${run_ui_e2e}" echo "run_ui=${run_ui}" echo "run_server=${run_server}" echo "run_cli=${run_cli}" @@ -192,6 +198,7 @@ jobs: needs: [resolve] uses: ./.github/workflows/tests.yml with: + run_ui_e2e: ${{ needs.resolve.outputs.run_ui_e2e == 'true' }} run_ui: ${{ needs.resolve.outputs.run_ui == 'true' }} run_server: ${{ needs.resolve.outputs.run_server == 'true' }} run_cli: ${{ needs.resolve.outputs.run_cli == 'true' }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8b79868fe..1a1dc992d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,8 +3,15 @@ name: CI — Tests on: pull_request: push: + branches: + - dev + - main workflow_call: inputs: + run_ui_e2e: + required: false + default: false + type: boolean run_ui: required: false default: true @@ -116,6 +123,93 @@ env: HAPPIER_UI_VENDOR_WEB_ASSETS: "0" jobs: + ui-e2e: + if: ${{ (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call') || (github.event_name == 'workflow_call' && inputs.run_ui_e2e) }} + name: UI E2E (Playwright) + runs-on: ubuntu-22.04 + timeout-minutes: 35 + env: + # UI E2E boots Expo web; keep web assets available to avoid CI-only runtime surprises. + HAPPIER_UI_VENDOR_WEB_ASSETS: "1" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect UI E2E-relevant changes + id: changes + if: github.event_name != 'workflow_call' + uses: dorny/paths-filter@v3 + with: + filters: | + ui_e2e: + - 'apps/ui/**' + - 'apps/server/**' + - 'apps/cli/**' + - 'packages/tests/**' + - 'package.json' + - 'yarn.lock' + + - name: Skip UI E2E (no relevant changes) + if: github.event_name != 'workflow_call' && steps.changes.outputs.ui_e2e != 'true' + run: | + echo "UI E2E skipped: no relevant changes." + + - name: Setup Node (GitHub Actions) + if: (github.event_name == 'workflow_call' || steps.changes.outputs.ui_e2e == 'true') && env.ACT != 'true' + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: yarn + cache-dependency-path: yarn.lock + + - name: Setup Node (act) + if: (github.event_name == 'workflow_call' || steps.changes.outputs.ui_e2e == 'true') && env.ACT == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22.x + + - name: Enable Corepack + if: github.event_name == 'workflow_call' || steps.changes.outputs.ui_e2e == 'true' + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + + - name: Install dependencies + if: github.event_name == 'workflow_call' || steps.changes.outputs.ui_e2e == 'true' + env: + YARN_PRODUCTION: "false" + npm_config_production: "false" + run: yarn install --frozen-lockfile + + - name: Install Playwright browsers + if: github.event_name == 'workflow_call' || steps.changes.outputs.ui_e2e == 'true' + run: npx playwright install --with-deps chromium + + - name: Install sqlite3 (if missing) + if: github.event_name == 'workflow_call' || steps.changes.outputs.ui_e2e == 'true' + run: | + if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 --version + exit 0 + fi + sudo apt-get update + sudo apt-get install -y sqlite3 + sqlite3 --version + + - name: Run UI E2E + if: github.event_name == 'workflow_call' || steps.changes.outputs.ui_e2e == 'true' + run: yarn -s test:e2e:ui + + - name: Upload UI E2E artifacts (Playwright) + if: (github.event_name == 'workflow_call' || steps.changes.outputs.ui_e2e == 'true') && failure() + uses: actions/upload-artifact@v4 + with: + name: ui-e2e-playwright-artifacts + path: | + packages/tests/.project/logs/e2e/ui-playwright + .project/logs/e2e/*ui-e2e* + if-no-files-found: ignore + ui: if: ${{ (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call') || inputs.run_ui }} name: UI Tests (unit + integration) @@ -429,6 +523,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22.x + - name: Verify published installers are synced run: | set -euo pipefail @@ -493,6 +592,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22.x + - name: Verify published installers are synced run: node scripts/pipeline/run.mjs release-sync-installers --check @@ -553,6 +657,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22.x + - name: Verify published installers are synced shell: pwsh run: node scripts/pipeline/run.mjs release-sync-installers --check @@ -997,7 +1106,7 @@ jobs: # Create a real account + token via the normal `/v1/auth` flow (ed25519 signature), # then write the credentials file expected by `.env.integration-test`. - node scripts/pipeline/testing/create-auth-credentials.mjs + node scripts/pipeline/run.mjs testing-create-auth-credentials yarn --cwd apps/cli -s vitest run --config vitest.integration.config.ts src/daemon/daemon.integration.test.ts diff --git a/.gitignore b/.gitignore index c9f782ce0..81fff5159 100644 --- a/.gitignore +++ b/.gitignore @@ -79,4 +79,14 @@ notes/ .project/qa/evidence-snapshots/ .project/qa/locks/ .project/qa/score-history/ +.happier/local/ /output + +# Claude Code cache +.claude/cache/ + +# Android build artifacts +packages/*/android/build/ +/.tmp +/packages/tests/playwright-report/ +/packages/tests/test-results/ diff --git a/AGENTS.md b/AGENTS.md index f1f0191bb..92b50fcd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,7 @@ Use these as canonical top-level lanes in this repository: - `yarn test:integration` (orchestration-heavy app integration lane) - `yarn test:e2e:core:fast` (default local core e2e loop) - `yarn test:e2e:core:slow` (long orchestration core e2e) +- `yarn test:e2e:ui` (UI/browser e2e via Playwright; exercises real UI + server + CLI/daemon flows) - `yarn test:providers` (provider contracts; opt-in/flag-driven) - `yarn test:db-contract:docker` (server db contract via docker) @@ -129,8 +130,18 @@ Naming and placement rules: - App integration tests: `*.integration.test.*`, `*.integration.spec.*`, `*.real.integration.test.*` - Core e2e slow tests: `packages/tests/suites/core-e2e/**/*.slow.e2e.test.ts` - Core e2e fast tests: other `packages/tests/suites/core-e2e/**/*.test.ts` +- UI Playwright e2e: `packages/tests/suites/ui-e2e/**/*.spec.ts` - Provider/stress suites remain under `packages/tests/suites/providers` and `packages/tests/suites/stress` +UI e2e authoring rules (Playwright + Expo web): +- Prefer stable selectors via React Native `testID` (queried in Playwright with `getByTestId(...)`); avoid selecting by visible copy. +- Treat `testID`s used by UI e2e as an API surface: avoid renames/removals unless you update the corresponding spec in the same PR. +- When adding `testID`s to shared RN components, ensure the web implementation forwards them to the DOM (typically `data-testid`) so Playwright can reliably locate elements. +- Keep UI e2e scenarios high-signal (onboarding, auth/terminal connect, session creation) and avoid duplicating core CLI-only e2e intent. +- If you change a flow that has a UI e2e, update the spec in `packages/tests/suites/ui-e2e/` in the same PR. +- UI e2e artifacts (screenshots/videos/diagnostics) are written under `packages/tests/.project/logs/e2e/ui-playwright/`. +- UI e2e runtime process logs (server/ui-web/daemon) are written under `.project/logs/e2e/*ui-e2e*/`. + When introducing or moving a lane/pattern, update all three in the same change: - package-level test config/scripts - root `package.json` lane scripts @@ -289,6 +300,10 @@ This repo has a single canonical feature gating system. New code must use it ins - Resolve feature decisions via `apps/ui/sources/sync/domains/features/featureDecisionRuntime.ts`. - When you must read server bits directly (rare), use `readServerEnabledBit(snapshot.features, featureId) === true`. - Do not treat missing/undefined as enabled. Prefer decisions (`FeatureDecision.state`) over raw booleans. +- UI design tokens: + - Colors must come from `apps/ui/sources/theme.ts` via Unistyles `theme.colors.*` (avoid hardcoded hex in UI code). + - Text must be rendered via `apps/ui/sources/components/ui/text/Text.tsx` so the user-selected in-app font size scales correctly (and stacks with OS Dynamic Type). + - All user-visible strings (including accessibility labels/placeholders) must use `t(...)` and be added to all locales under `apps/ui/sources/text/translations/`. ### Voice (Happier Voice) special note - `voice.happierVoice` is a first-class SERVER feature gate and must be explicitly provided by the server. @@ -301,6 +316,45 @@ This repo has a single canonical feature gating system. New code must use it ins - Vitest automatically excludes denied feature tests using `scripts/testing/featureTestGating.ts` (dependency closure included). - Use `HAPPIER_TEST_FEATURES_DENY` (in addition to `HAPPIER_BUILD_FEATURES_DENY`) when you need to disable a feature’s tests in CI without changing the embedded policy. +## Encryption storage modes (E2EE vs plaintext storage) + +This repo supports both encrypted-at-rest (E2EE-style) and plaintext-at-rest session storage. Treat this as a **storage-mode** choice; it is **not** the same thing as transport security (TLS) or authentication (key-challenge login still exists). + +### Concepts (authoritative contracts) +- **Server storage policy**: `required_e2ee | optional | plaintext_only` (server config; surfaced via `/v1/features`). +- **Account encryption mode**: `e2ee | plain` (affects *new* sessions by default). +- **Session encryption mode**: `e2ee | plain` (fixed at session creation; avoids mixed-mode transcripts). +- **Message content envelope** (server storage + API contract): + - `{ t: 'encrypted', c: string }` (ciphertext base64) + - `{ t: 'plain', v: unknown }` (raw transcript record) +- Pending queue v2 uses the same envelope (`content`) alongside the legacy `ciphertext` shape. + +### Implementation rules (do not regress) +- Always enforce **mode/content-kind compatibility** at write choke points (HTTP + sockets + pending): + - `e2ee` session ⇒ accept encrypted content only + - `plain` session ⇒ accept plain content only +- Sharing: + - For `plain` sessions: sharing must work without `encryptedDataKey` (server-managed access). + - For `e2ee` sessions: sharing/public-share must require a valid `encryptedDataKey` envelope. +- Do not add client-side “guessing” (e.g. assuming encrypted). Parse the envelope and branch behavior explicitly. +- All gating must use the canonical feature system: + - feature ids: `encryption.plaintextStorage`, `encryption.accountOptOut` + - do not gate client behavior on raw env vars or `capabilities` fields. + +### Core E2E expectations (keep fast lane small) +Do **not** duplicate the entire core-e2e suite across both modes. Instead: +- Keep the existing suite exercising default encrypted behavior. +- Add **targeted** plaintext-specific E2E tests for each mode-sensitive workflow you touch. +- Add **targeted** encrypted regressions when contracts change (e.g. “must require encryptedDataKey in e2ee”). + +Plaintext storage E2E tests live under `packages/tests/suites/core-e2e/` and are feature-gated via filename markers: +- `encryption.plaintextStorage.*.feat.encryption.plaintextStorage.*.e2e.test.ts` +- Sharing plaintext coverage additionally includes `.feat.sharing.public.`, `.feat.sharing.session.`, `.feat.sharing.pendingQueueV2.`, etc. + +Testkit notes: +- Social friends setup helpers: `packages/tests/src/testkit/socialFriends.ts` +- Pending queue v2 testkit currently models encrypted-only rows; plaintext pending E2E should use direct `fetchJson` unless/until the helper is generalized. + ## UI App Structure Rules (Happier UI) Applies to `apps/ui/sources`. @@ -641,3 +695,19 @@ CRITICAL: Keep TypeScript strict everywhere CRITICAL: Enforce file size and responsibility boundaries If a file is large or multi-purpose, split it by domain/responsibility instead of expanding a monolith. + +## Encryption Opt-Out / Plaintext Session Storage (2026-02) + +Sessions can be stored in two modes, controlled by `Session.encryptionMode`: +- `e2ee`: message/pending content is `{ t: 'encrypted', c: }` and must be decrypted client-side. +- `plain`: message/pending content is `{ t: 'plain', v: }` and must *not* be decrypted client-side. + +Server policy is advertised in `/v1/features`: +- gate: `features.encryption.plaintextStorage.enabled` / `features.encryption.accountOptOut.enabled` +- details: `capabilities.encryption.storagePolicy` (`required_e2ee | optional | plaintext_only`) + +Implementation rule of thumb: +- Never assume `content.t === 'encrypted'`; always branch on the envelope. +- In `plain` sessions, bypass encrypt/decrypt for `metadata`, `agentState`, messages, and pending rows. + +Core e2e coverage lives under `packages/tests/suites/core-e2e/` and includes plaintext roundtrip scenarios (including public share + pending queue v2). diff --git a/Dockerfile b/Dockerfile index eb500cc3c..4bd837ed4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ ENV REDISMS_DISABLE_POSTINSTALL=1 ENV YARN_CACHE_FOLDER=/tmp/.yarn-cache COPY package.json yarn.lock ./ -RUN mkdir -p apps/ui apps/server apps/cli apps/website apps/docs packages/agents packages/cli-common packages/protocol packages/audio-stream-native packages/sherpa-native +RUN mkdir -p apps/ui apps/server apps/cli apps/website apps/docs packages/agents packages/cli-common packages/protocol packages/release-runtime packages/audio-stream-native packages/sherpa-native COPY apps/ui/package.json apps/ui/ COPY apps/server/package.json apps/server/ COPY apps/cli/package.json apps/cli/ @@ -20,6 +20,7 @@ COPY apps/docs/package.json apps/docs/ COPY packages/agents/package.json packages/agents/ COPY packages/cli-common/package.json packages/cli-common/ COPY packages/protocol/package.json packages/protocol/ +COPY packages/release-runtime/package.json packages/release-runtime/ COPY packages/audio-stream-native/package.json packages/audio-stream-native/ COPY packages/sherpa-native/package.json packages/sherpa-native/ @@ -40,7 +41,7 @@ ENV REDISMS_DISABLE_POSTINSTALL=1 ENV YARN_CACHE_FOLDER=/tmp/.yarn-cache COPY package.json yarn.lock ./ -RUN mkdir -p apps/ui apps/server apps/cli apps/website apps/docs packages/agents packages/cli-common packages/protocol packages/audio-stream-native packages/sherpa-native +RUN mkdir -p apps/ui apps/server apps/cli apps/website apps/docs packages/agents packages/cli-common packages/protocol packages/release-runtime packages/audio-stream-native packages/sherpa-native COPY apps/ui/package.json apps/ui/ COPY apps/server/package.json apps/server/ COPY apps/cli/package.json apps/cli/ @@ -49,6 +50,7 @@ COPY apps/docs/package.json apps/docs/ COPY packages/agents/package.json packages/agents/ COPY packages/cli-common/package.json packages/cli-common/ COPY packages/protocol/package.json packages/protocol/ +COPY packages/release-runtime/package.json packages/release-runtime/ COPY packages/audio-stream-native/package.json packages/audio-stream-native/ COPY packages/sherpa-native/package.json packages/sherpa-native/ @@ -67,7 +69,7 @@ ENV REDISMS_DISABLE_POSTINSTALL=1 ENV YARN_CACHE_FOLDER=/tmp/.yarn-cache COPY package.json yarn.lock ./ -RUN mkdir -p apps/ui apps/server apps/cli apps/website apps/docs packages/agents packages/cli-common packages/protocol packages/audio-stream-native packages/sherpa-native +RUN mkdir -p apps/ui apps/server apps/cli apps/website apps/docs packages/agents packages/cli-common packages/protocol packages/release-runtime packages/audio-stream-native packages/sherpa-native COPY apps/ui/package.json apps/ui/ COPY apps/server/package.json apps/server/ COPY apps/cli/package.json apps/cli/ @@ -76,6 +78,7 @@ COPY apps/docs/package.json apps/docs/ COPY packages/agents/package.json packages/agents/ COPY packages/cli-common/package.json packages/cli-common/ COPY packages/protocol/package.json packages/protocol/ +COPY packages/release-runtime/package.json packages/release-runtime/ COPY packages/audio-stream-native/package.json packages/audio-stream-native/ COPY packages/sherpa-native/package.json packages/sherpa-native/ diff --git a/apps/cli/package.json b/apps/cli/package.json index 12e64e826..2f71c4a63 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -60,7 +60,8 @@ "bundledDependencies": [ "@happier-dev/agents", "@happier-dev/cli-common", - "@happier-dev/protocol" + "@happier-dev/protocol", + "@happier-dev/release-runtime" ], "scripts": { "typecheck": "tsc --noEmit", @@ -110,6 +111,7 @@ "@happier-dev/agents": "0.0.0", "@happier-dev/cli-common": "0.0.0", "@happier-dev/protocol": "0.0.0", + "@happier-dev/release-runtime": "0.0.0", "@modelcontextprotocol/sdk": "^1.25.3", "@stablelib/base64": "^2.0.1", "@stablelib/hex": "^2.0.1", diff --git a/apps/cli/scripts/__tests__/buildSharedDeps.test.ts b/apps/cli/scripts/__tests__/buildSharedDeps.test.ts index e70f955d5..73834945c 100644 --- a/apps/cli/scripts/__tests__/buildSharedDeps.test.ts +++ b/apps/cli/scripts/__tests__/buildSharedDeps.test.ts @@ -74,4 +74,38 @@ describe('buildSharedDeps', () => { expect(dest).toBe('/repo/apps/cli/node_modules/@happier-dev/protocol/dist'); expect(opts).toMatchObject({ recursive: true, force: true }); }); + + it('syncs bundled workspace package.json exports when present', () => { + const cpSync = vi.fn(() => undefined); + const existsSync = vi.fn((p: any) => + String(p).includes('/apps/cli/node_modules/@happier-dev/protocol/dist') || + String(p).endsWith('/apps/cli/node_modules/@happier-dev/protocol/package.json'), + ); + const readFileSync = vi.fn(() => + JSON.stringify({ + name: '@happier-dev/protocol', + version: '0.0.0', + type: 'module', + exports: { '.': { default: './dist/index.js' }, './installables': { default: './dist/installables.js' } }, + dependencies: { zod: '1.0.0' }, + }), + ); + const writeFileSync = vi.fn(() => undefined); + + syncBundledWorkspaceDist({ + repoRoot: '/repo', + cpSync, + existsSync, + readFileSync, + writeFileSync, + packages: ['protocol'], + }); + + expect(writeFileSync).toHaveBeenCalledTimes(1); + const [destPath, payload] = writeFileSync.mock.calls[0] ?? []; + expect(destPath).toBe('/repo/apps/cli/node_modules/@happier-dev/protocol/package.json'); + const parsed = JSON.parse(String(payload)); + expect(parsed.exports?.['./installables']).toBeTruthy(); + expect(parsed.private).toBe(true); + }); }); diff --git a/apps/cli/scripts/__tests__/bundleWorkspaceDeps.test.ts b/apps/cli/scripts/__tests__/bundleWorkspaceDeps.test.ts index d19d3998b..ef8050ce0 100644 --- a/apps/cli/scripts/__tests__/bundleWorkspaceDeps.test.ts +++ b/apps/cli/scripts/__tests__/bundleWorkspaceDeps.test.ts @@ -41,11 +41,13 @@ describe('bundleWorkspaceDeps', () => { const agentsDir = resolve(repoRoot, 'packages', 'agents'); const cliCommonDir = resolve(repoRoot, 'packages', 'cli-common'); const protocolDir = resolve(repoRoot, 'packages', 'protocol'); + const releaseRuntimeDir = resolve(repoRoot, 'packages', 'release-runtime'); const happyCliDir = resolve(repoRoot, 'apps', 'cli'); mkdirSync(resolve(agentsDir, 'dist'), { recursive: true }); mkdirSync(resolve(cliCommonDir, 'dist'), { recursive: true }); mkdirSync(resolve(protocolDir, 'dist'), { recursive: true }); + mkdirSync(resolve(releaseRuntimeDir, 'dist'), { recursive: true }); mkdirSync(happyCliDir, { recursive: true }); writeJson(resolve(agentsDir, 'package.json'), { @@ -81,10 +83,21 @@ describe('bundleWorkspaceDeps', () => { exports: { '.': { default: './dist/index.js', types: './dist/index.d.ts' } }, scripts: { postinstall: 'echo should-not-run' }, }); + writeJson(resolve(releaseRuntimeDir, 'package.json'), { + name: '@happier-dev/release-runtime', + version: '0.0.0', + type: 'module', + main: './dist/index.js', + types: './dist/index.d.ts', + exports: { '.': { default: './dist/index.js', types: './dist/index.d.ts' } }, + scripts: { postinstall: 'echo should-not-run' }, + devDependencies: { typescript: '^5' }, + }); writeFileSync(resolve(agentsDir, 'dist', 'index.js'), 'export const x = 1;\n', 'utf8'); writeFileSync(resolve(protocolDir, 'dist', 'index.js'), 'export const y = 2;\n', 'utf8'); writeFileSync(resolve(cliCommonDir, 'dist', 'index.js'), 'export const z = 3;\n', 'utf8'); + writeFileSync(resolve(releaseRuntimeDir, 'dist', 'index.js'), 'export const w = 4;\n', 'utf8'); bundleWorkspaceDeps({ repoRoot, happyCliDir }); @@ -118,6 +131,9 @@ describe('bundleWorkspaceDeps', () => { const bundledCommonPkgJson = JSON.parse( readFileSync(resolve(happyCliDir, 'node_modules', '@happier-dev', 'cli-common', 'package.json'), 'utf8'), ); + const bundledReleaseRuntimePkgJson = JSON.parse( + readFileSync(resolve(happyCliDir, 'node_modules', '@happier-dev', 'release-runtime', 'package.json'), 'utf8'), + ); expect(bundledAgentsPkgJson.scripts).toBeUndefined(); expect(bundledAgentsPkgJson.devDependencies).toBeUndefined(); @@ -128,6 +144,10 @@ describe('bundleWorkspaceDeps', () => { expect(bundledCommonPkgJson.scripts).toBeUndefined(); expect(bundledCommonPkgJson.name).toBe('@happier-dev/cli-common'); + + expect(bundledReleaseRuntimePkgJson.scripts).toBeUndefined(); + expect(bundledReleaseRuntimePkgJson.devDependencies).toBeUndefined(); + expect(bundledReleaseRuntimePkgJson.name).toBe('@happier-dev/release-runtime'); }); it('vendors the external runtime dependency tree for bundled workspace packages', () => { @@ -136,12 +156,14 @@ describe('bundleWorkspaceDeps', () => { writeFileSync(resolve(repoRoot, 'yarn.lock'), '# lock\n', 'utf8'); const protocolDir = resolve(repoRoot, 'packages', 'protocol'); + const releaseRuntimeDir = resolve(repoRoot, 'packages', 'release-runtime'); const happyCliDir = resolve(repoRoot, 'apps', 'cli'); const depADir = resolve(repoRoot, 'node_modules', 'dep-a'); const depBDir = resolve(repoRoot, 'node_modules', 'dep-b'); mkdirSync(resolve(protocolDir, 'dist'), { recursive: true }); + mkdirSync(resolve(releaseRuntimeDir, 'dist'), { recursive: true }); mkdirSync(happyCliDir, { recursive: true }); mkdirSync(depADir, { recursive: true }); mkdirSync(depBDir, { recursive: true }); @@ -158,6 +180,15 @@ describe('bundleWorkspaceDeps', () => { }, }); writeFileSync(resolve(protocolDir, 'dist', 'index.js'), 'export const y = 2;\n', 'utf8'); + writeJson(resolve(releaseRuntimeDir, 'package.json'), { + name: '@happier-dev/release-runtime', + version: '0.0.0', + type: 'module', + main: './dist/index.js', + types: './dist/index.d.ts', + exports: { '.': { default: './dist/index.js', types: './dist/index.d.ts' } }, + }); + writeFileSync(resolve(releaseRuntimeDir, 'dist', 'index.js'), 'export const w = 4;\n', 'utf8'); writeJson(resolve(depADir, 'package.json'), { name: 'dep-a', diff --git a/apps/cli/scripts/__tests__/publishBundledDependencies.test.ts b/apps/cli/scripts/__tests__/publishBundledDependencies.test.ts index 3acbbae51..b5d806509 100644 --- a/apps/cli/scripts/__tests__/publishBundledDependencies.test.ts +++ b/apps/cli/scripts/__tests__/publishBundledDependencies.test.ts @@ -19,6 +19,7 @@ describe('apps/cli package publish contract', () => { expect(bundled).toContain('@happier-dev/agents'); expect(bundled).toContain('@happier-dev/cli-common'); expect(bundled).toContain('@happier-dev/protocol'); + expect(bundled).toContain('@happier-dev/release-runtime'); // External runtime deps used by protocol should be declared on protocol itself // (and vendored into the bundled protocol package during `prepack`). diff --git a/apps/cli/scripts/buildSharedDeps.mjs b/apps/cli/scripts/buildSharedDeps.mjs index d0852e7ad..97f5c95a5 100644 --- a/apps/cli/scripts/buildSharedDeps.mjs +++ b/apps/cli/scripts/buildSharedDeps.mjs @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { cpSync, existsSync, realpathSync } from 'node:fs'; +import { cpSync, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -76,6 +76,8 @@ export function syncBundledWorkspaceDist(opts = {}) { const repoRoot = typeof repoRootArg === 'string' && repoRootArg.trim() ? repoRootArg : findRepoRoot(__dirname); const exists = opts.existsSync ?? existsSync; const cp = opts.cpSync ?? cpSync; + const readFile = opts.readFileSync ?? readFileSync; + const writeFile = opts.writeFileSync ?? writeFileSync; const packages = Array.isArray(opts.packages) && opts.packages.length > 0 ? opts.packages : ['agents', 'cli-common', 'protocol']; for (const pkg of packages) { @@ -87,9 +89,50 @@ export function syncBundledWorkspaceDist(opts = {}) { } catch { // Best-effort: bundled deps may be missing or readonly. } + + const destPackageJsonPath = resolve(repoRoot, 'apps', 'cli', 'node_modules', '@happier-dev', pkg, 'package.json'); + if (!exists(destPackageJsonPath)) continue; + try { + const raw = JSON.parse(readFile(resolve(repoRoot, 'packages', pkg, 'package.json'), 'utf8')); + const sanitized = sanitizeBundledWorkspacePackageJson(raw); + writeFile(destPackageJsonPath, `${JSON.stringify(sanitized, null, 2)}\n`, 'utf8'); + } catch { + // Best-effort: keep local bundled deps usable even if package.json sync fails. + } } } +function sanitizeBundledWorkspacePackageJson(raw) { + const { + name, + version, + type, + main, + module, + types, + exports, + dependencies, + peerDependencies, + optionalDependencies, + engines, + } = raw ?? {}; + + return { + name, + version, + private: true, + type, + main, + module, + types, + exports, + dependencies, + peerDependencies, + optionalDependencies, + engines, + }; +} + export function main() { runTsc(resolve(repoRoot, 'packages', 'agents', 'tsconfig.json')); runTsc(resolve(repoRoot, 'packages', 'cli-common', 'tsconfig.json')); diff --git a/apps/cli/scripts/bundleWorkspaceDeps.mjs b/apps/cli/scripts/bundleWorkspaceDeps.mjs index b5b896e59..2f78a673f 100644 --- a/apps/cli/scripts/bundleWorkspaceDeps.mjs +++ b/apps/cli/scripts/bundleWorkspaceDeps.mjs @@ -30,6 +30,11 @@ export function bundleWorkspaceDeps(opts = {}) { srcDir: resolve(repoRoot, 'packages', 'protocol'), destDir: resolve(happyCliDir, 'node_modules', '@happier-dev', 'protocol'), }, + { + packageName: '@happier-dev/release-runtime', + srcDir: resolve(repoRoot, 'packages', 'release-runtime'), + destDir: resolve(happyCliDir, 'node_modules', '@happier-dev', 'release-runtime'), + }, ]; bundleWorkspacePackages({ bundles }); diff --git a/apps/cli/src/agent/acp/AcpBackend.ts b/apps/cli/src/agent/acp/AcpBackend.ts index 5bdfb4417..8885cadc6 100644 --- a/apps/cli/src/agent/acp/AcpBackend.ts +++ b/apps/cli/src/agent/acp/AcpBackend.ts @@ -1676,7 +1676,7 @@ export class AcpBackend implements AgentBackend { .map((model: unknown) => asRecord(model)) .filter((model): model is Record => Boolean(model)) .map((model) => { - const id = getString(model, 'id'); + const id = getString(model, 'id') ?? getString(model, 'modelId'); const name = getString(model, 'name'); if (!id || !name) return null; const description = getString(model, 'description'); @@ -1876,7 +1876,10 @@ export class AcpBackend implements AgentBackend { // Don't resolve immediately: give stderr/process-exit handlers a chance to surface errors // before we declare the turn complete (prevents swallowing "exit non-zero" or auth errors). const transportIdleTimeoutMs = this.transport.getIdleTimeout?.() ?? DEFAULT_IDLE_TIMEOUT_MS; - const graceMs = Math.max(25, transportIdleTimeoutMs); + // NOTE: When an ACP agent crashes/exits shortly after responding to session/prompt, the + // subprocess exit can race with our "no updates" idle fallback. Use a small minimum grace + // to reduce flakes and avoid incorrectly treating a failed turn as complete. + const graceMs = Math.max(35, transportIdleTimeoutMs); if (this.postPromptCompletionIdleTimeout) { clearTimeout(this.postPromptCompletionIdleTimeout); } diff --git a/apps/cli/src/agent/acp/__tests__/AcpBackend.permissionSeed.toolCallUpdateFallback.test.ts b/apps/cli/src/agent/acp/__tests__/AcpBackend.permissionSeed.toolCallUpdateFallback.test.ts index ae60bac66..f4c0e230c 100644 --- a/apps/cli/src/agent/acp/__tests__/AcpBackend.permissionSeed.toolCallUpdateFallback.test.ts +++ b/apps/cli/src/agent/acp/__tests__/AcpBackend.permissionSeed.toolCallUpdateFallback.test.ts @@ -40,7 +40,7 @@ function writeFakeAcpAgentScript(params: { dir: string }): string { waitingForPermResponse = true; } - function sendToolCallUpdateAndMessage() { + function sendToolCallUpdateAndMessage() { send({ jsonrpc: '2.0', method: 'session/update', @@ -78,7 +78,7 @@ function writeFakeAcpAgentScript(params: { dir: string }): string { }); } - process.stdin.on('data', (chunk) => { + process.stdin.on('data', (chunk) => { buf += decoder.decode(chunk, { stream: true }); const lines = buf.split('\\n'); buf = lines.pop() || ''; @@ -90,12 +90,12 @@ function writeFakeAcpAgentScript(params: { dir: string }): string { try { req = JSON.parse(trimmed); } catch { continue; } if (!req || typeof req !== 'object') continue; - // Handle client responses (permission request response) - if (waitingForPermResponse && req.id === 'perm-1') { - waitingForPermResponse = false; - setTimeout(sendToolCallUpdateAndMessage, 10); - continue; - } + // Handle client responses (permission request response) + if (waitingForPermResponse && req.id === 'perm-1') { + waitingForPermResponse = false; + setTimeout(sendToolCallUpdateAndMessage, 0); + continue; + } const id = req.id; const method = req.method; @@ -111,11 +111,11 @@ function writeFakeAcpAgentScript(params: { dir: string }): string { continue; } - if (method === 'session/prompt') { - ok(id, {}); - setTimeout(sendPermissionRequest, 10); - continue; - } + if (method === 'session/prompt') { + ok(id, {}); + setTimeout(sendPermissionRequest, 0); + continue; + } ok(id, {}); } @@ -127,29 +127,29 @@ function writeFakeAcpAgentScript(params: { dir: string }): string { } describe('AcpBackend permission seed + tool_call_update fallback', () => { - it('reuses tool name from permission request when tool_call_update lacks kind, and preserves content when output is empty', async () => { + it('reuses tool name from permission request when tool_call_update lacks kind, and preserves content when output is empty', async () => { const dir = mkdtempSync(join(tmpdir(), 'happier-acp-perm-seed-')); const scriptPath = writeFakeAcpAgentScript({ dir }); let backendForCleanup: AcpBackend | undefined; try { - const backend = new AcpBackend({ + const backend = new AcpBackend({ agentName: 'test', cwd: dir, command: process.execPath, args: [scriptPath], - transportHandler: { - agentName: 'test', - getInitTimeout: () => 1_000, - getToolPatterns: () => [] as ToolPattern[], - getIdleTimeout: () => 1, - } satisfies TransportHandler, - permissionHandler: { - async handleToolCall() { - return { decision: 'approved' as const }; - }, - }, - }); + transportHandler: { + agentName: 'test', + getInitTimeout: () => 1_000, + getToolPatterns: () => [] as ToolPattern[], + getIdleTimeout: () => 50, + } satisfies TransportHandler, + permissionHandler: { + async handleToolCall() { + return { decision: 'approved' as const }; + }, + }, + }); backendForCleanup = backend; const toolResults: Array<{ toolName: string; result: unknown }> = []; @@ -158,16 +158,21 @@ describe('AcpBackend permission seed + tool_call_update fallback', () => { toolResults.push({ toolName: msg.toolName, result: msg.result }); }); - const started = await backend.startSession(); - await backend.sendPrompt(started.sessionId, 'hi'); - await backend.waitForResponseComplete(5_000); - - expect(toolResults.length).toBeGreaterThan(0); - const last = toolResults[toolResults.length - 1]!; - expect(last.toolName).toBe('execute'); - expect(last.result).not.toBe(''); - expect(Array.isArray(last.result)).toBe(true); - } finally { + const started = await backend.startSession(); + await backend.sendPrompt(started.sessionId, 'hi'); + await backend.waitForResponseComplete(5_000); + + const startMs = Date.now(); + while (toolResults.length === 0 && Date.now() - startMs < 2_000) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expect(toolResults.length).toBeGreaterThan(0); + const last = toolResults[toolResults.length - 1]!; + expect(last.toolName).toBe('execute'); + expect(last.result).not.toBe(''); + expect(Array.isArray(last.result)).toBe(true); + } finally { await backendForCleanup?.dispose().catch(() => {}); rmSync(dir, { recursive: true, force: true }); } diff --git a/apps/cli/src/agent/acp/runtime/__tests__/createAcpRuntime.inFlightSteer.process.test.ts b/apps/cli/src/agent/acp/runtime/__tests__/createAcpRuntime.inFlightSteer.process.test.ts index 66bae45c9..15198ff67 100644 --- a/apps/cli/src/agent/acp/runtime/__tests__/createAcpRuntime.inFlightSteer.process.test.ts +++ b/apps/cli/src/agent/acp/runtime/__tests__/createAcpRuntime.inFlightSteer.process.test.ts @@ -105,14 +105,15 @@ describe('createAcpRuntime (in-flight steer, real process)', () => { cwd: dir, command: process.execPath, args: [scriptPath], - transportHandler: { - agentName: 'test', - getInitTimeout: () => 1_000, - getToolPatterns: () => [] as ToolPattern[], - // Keep the prompt "in flight" briefly after the chunk is emitted. - getIdleTimeout: () => 25, - } satisfies TransportHandler, - }); + transportHandler: { + agentName: 'test', + getInitTimeout: () => 1_000, + getToolPatterns: () => [] as ToolPattern[], + // Keep the prompt "in flight" long enough for the chunk to arrive before the backend's + // post-prompt "no updates" idle fallback fires. + getIdleTimeout: () => 250, + } satisfies TransportHandler, + }); const sent: Array<{ type: string; [k: string]: unknown }> = []; const session = { @@ -127,6 +128,16 @@ describe('createAcpRuntime (in-flight steer, real process)', () => { } as any; try { + const waitForMessage = async (): Promise<{ type: string; [k: string]: unknown } | null> => { + const deadlineMs = Date.now() + 2_000; + while (Date.now() < deadlineMs) { + const found = sent.find((m) => m.type === 'message' && typeof (m as any)?.message === 'string'); + if (found) return found; + await new Promise((r) => setTimeout(r, 10)); + } + return null; + }; + const runtime = createAcpRuntime({ provider: 'codex', directory: dir, @@ -149,9 +160,10 @@ describe('createAcpRuntime (in-flight steer, real process)', () => { await primaryPromise; runtime.flushTurn(); - const message = sent.find((m) => m.type === 'message') as any; - expect(message?.message).toContain('primary=hello'); - expect(message?.message).toContain('steer=steer-now'); + const message = await waitForMessage(); + expect(message).not.toBeNull(); + expect((message as any)?.message).toContain('primary=hello'); + expect((message as any)?.message).toContain('steer=steer-now'); } finally { await backend.dispose().catch(() => {}); rmSync(dir, { recursive: true, force: true }); diff --git a/apps/cli/src/agent/acp/runtime/__tests__/createAcpRuntime.modelOutputStreaming.test.ts b/apps/cli/src/agent/acp/runtime/__tests__/createAcpRuntime.modelOutputStreaming.test.ts index d1f55f45d..ce89d0418 100644 --- a/apps/cli/src/agent/acp/runtime/__tests__/createAcpRuntime.modelOutputStreaming.test.ts +++ b/apps/cli/src/agent/acp/runtime/__tests__/createAcpRuntime.modelOutputStreaming.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { MessageBuffer } from '@/ui/ink/messageBuffer'; import type { ACPMessageData } from '@/api/session/sessionMessageTypes'; @@ -8,10 +8,77 @@ import { createAcpRuntime } from '../createAcpRuntime'; import { createFakeAcpRuntimeBackend, createApprovedPermissionHandler } from '../createAcpRuntime.testkit'; describe('createAcpRuntime (model-output delta streaming)', () => { - it('forwards model-output text deltas as ACP message chunks with a stable happierStreamKey per turn', async () => { + it('debounces model-output text deltas and forwards chunks with a stable happierStreamKey per turn', async () => { const backend = createFakeAcpRuntimeBackend({ sessionId: 'sess_main' }); const sent: Array<{ body: ACPMessageData; meta?: Record }> = []; + vi.useFakeTimers(); + + const runtime = createAcpRuntime({ + provider: 'claude', + directory: '/tmp', + session: { + keepAlive: () => {}, + sendAgentMessage: (_provider, body, opts) => { + sent.push({ body, meta: opts?.meta }); + }, + sendAgentMessageCommitted: async () => {}, + sendUserTextMessageCommitted: async () => {}, + fetchRecentTranscriptTextItemsForAcpImport: async () => [], + updateMetadata: () => {}, + }, + messageBuffer: new MessageBuffer(), + mcpServers: {}, + permissionHandler: createApprovedPermissionHandler(), + onThinkingChange: () => {}, + ensureBackend: async () => backend, + }); + + try { + await runtime.startOrLoad({}); + runtime.beginTurn(); + + backend.emit({ type: 'model-output', textDelta: 'Hello' } satisfies AgentMessage); + await vi.advanceTimersByTimeAsync(60); + + backend.emit({ type: 'model-output', textDelta: ' world' } satisfies AgentMessage); + await vi.advanceTimersByTimeAsync(60); + + const chunkMessages = sent.filter((m) => m.body.type === 'message'); + expect(chunkMessages.length).toBeGreaterThanOrEqual(2); + expect(chunkMessages[0]?.body.type).toBe('message'); + expect((chunkMessages[0]?.body as any).message).toBe('Hello'); + expect((chunkMessages[1]?.body as any).message).toBe(' world'); + + const k0 = (chunkMessages[0]?.meta as any)?.happierStreamKey; + const k1 = (chunkMessages[1]?.meta as any)?.happierStreamKey; + expect(typeof k0).toBe('string'); + expect(k0).toBe(k1); + + runtime.flushTurn(); + + runtime.beginTurn(); + backend.emit({ type: 'model-output', textDelta: 'Second' } satisfies AgentMessage); + await vi.advanceTimersByTimeAsync(60); + + const next = sent.filter((m) => m.body.type === 'message').slice(-1)[0]; + expect((next?.body as any)?.message).toBe('Second'); + const k2 = (next?.meta as any)?.happierStreamKey; + expect(typeof k2).toBe('string'); + expect(k2).not.toBe(k0); + } finally { + vi.useRealTimers(); + } + }); + + it('can disable streaming debounce and forward each delta immediately', async () => { + vi.stubEnv('HAPPIER_ACP_STREAM_DELTA_FLUSH_MS', '0'); + + const backend = createFakeAcpRuntimeBackend({ sessionId: 'sess_main' }); + const sent: Array<{ body: ACPMessageData; meta?: Record }> = []; + + vi.useFakeTimers(); + const runtime = createAcpRuntime({ provider: 'claude', directory: '/tmp', @@ -32,6 +99,70 @@ describe('createAcpRuntime (model-output delta streaming)', () => { ensureBackend: async () => backend, }); + try { + await runtime.startOrLoad({}); + runtime.beginTurn(); + + backend.emit({ type: 'model-output', textDelta: 'Hello' } satisfies AgentMessage); + await vi.advanceTimersByTimeAsync(60); + + backend.emit({ type: 'model-output', textDelta: ' world' } satisfies AgentMessage); + await vi.advanceTimersByTimeAsync(60); + + const chunkMessages = sent.filter((m) => m.body.type === 'message'); + expect(chunkMessages.length).toBeGreaterThanOrEqual(2); + expect(chunkMessages[0]?.body.type).toBe('message'); + expect((chunkMessages[0]?.body as any).message).toBe('Hello'); + expect((chunkMessages[1]?.body as any).message).toBe(' world'); + + const k0 = (chunkMessages[0]?.meta as any)?.happierStreamKey; + const k1 = (chunkMessages[1]?.meta as any)?.happierStreamKey; + expect(typeof k0).toBe('string'); + expect(k0).toBe(k1); + + runtime.flushTurn(); + + runtime.beginTurn(); + backend.emit({ type: 'model-output', textDelta: 'Second' } satisfies AgentMessage); + await vi.advanceTimersByTimeAsync(60); + + const next = sent.filter((m) => m.body.type === 'message').slice(-1)[0]; + expect((next?.body as any)?.message).toBe('Second'); + const k2 = (next?.meta as any)?.happierStreamKey; + expect(typeof k2).toBe('string'); + expect(k2).not.toBe(k0); + } finally { + vi.useRealTimers(); + } + }); + + it('can disable streaming debounce and forward each delta immediately', async () => { + vi.stubEnv('HAPPIER_ACP_STREAM_DELTA_FLUSH_MS', '0'); + + const backend = createFakeAcpRuntimeBackend({ sessionId: 'sess_main' }); + const sent: Array<{ body: ACPMessageData; meta?: Record }> = []; + + const runtime = createAcpRuntime({ + provider: 'claude', + directory: '/tmp', + session: { + keepAlive: () => {}, + sendAgentMessage: (_provider, body, opts) => { + sent.push({ body, meta: opts?.meta }); + }, + sendAgentMessageCommitted: async () => {}, + sendUserTextMessageCommitted: async () => {}, + fetchRecentTranscriptTextItemsForAcpImport: async () => [], + updateMetadata: () => {}, + }, + messageBuffer: new MessageBuffer(), + mcpServers: {}, + permissionHandler: createApprovedPermissionHandler(), + onThinkingChange: () => {}, + ensureBackend: async () => backend, + modelOutputStreaming: { deltaFlushIntervalMs: 0 }, + }); + await runtime.startOrLoad({}); runtime.beginTurn(); @@ -40,7 +171,6 @@ describe('createAcpRuntime (model-output delta streaming)', () => { const chunkMessages = sent.filter((m) => m.body.type === 'message'); expect(chunkMessages.length).toBeGreaterThanOrEqual(2); - expect(chunkMessages[0]?.body.type).toBe('message'); expect((chunkMessages[0]?.body as any).message).toBe('Hello'); expect((chunkMessages[1]?.body as any).message).toBe(' world'); @@ -48,15 +178,5 @@ describe('createAcpRuntime (model-output delta streaming)', () => { const k1 = (chunkMessages[1]?.meta as any)?.happierStreamKey; expect(typeof k0).toBe('string'); expect(k0).toBe(k1); - - runtime.flushTurn(); - - runtime.beginTurn(); - backend.emit({ type: 'model-output', textDelta: 'Second' } satisfies AgentMessage); - const next = sent.filter((m) => m.body.type === 'message').slice(-1)[0]; - expect((next?.body as any)?.message).toBe('Second'); - const k2 = (next?.meta as any)?.happierStreamKey; - expect(typeof k2).toBe('string'); - expect(k2).not.toBe(k0); }); }); diff --git a/apps/cli/src/agent/acp/runtime/createAcpRuntime.ts b/apps/cli/src/agent/acp/runtime/createAcpRuntime.ts index ebcf90b29..828b3458f 100644 --- a/apps/cli/src/agent/acp/runtime/createAcpRuntime.ts +++ b/apps/cli/src/agent/acp/runtime/createAcpRuntime.ts @@ -21,6 +21,21 @@ import type { AcpRuntimeSessionClient } from '@/agent/acp/sessionClient'; import { getAgentModelConfig, type AgentId } from '@happier-dev/agents'; import { updateMetadataBestEffort } from '@/api/session/sessionWritesBestEffort'; +const DEFAULT_STREAM_DELTA_FLUSH_INTERVAL_MS = 50; + +function resolveStreamDeltaFlushIntervalMs(input: unknown): number { + if (typeof input === 'number' && Number.isFinite(input) && input >= 0) { + return Math.trunc(input); + } + + const raw = (process.env.HAPPIER_ACP_STREAM_DELTA_FLUSH_MS ?? '').toString().trim(); + if (!raw) return DEFAULT_STREAM_DELTA_FLUSH_INTERVAL_MS; + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_STREAM_DELTA_FLUSH_INTERVAL_MS; + return Math.trunc(parsed); +} + export type AcpRuntime = Readonly<{ getSessionId: () => string | null; /** @@ -180,6 +195,15 @@ export function createAcpRuntime(params: { sendToolResult: (params: { callId: string; output: unknown }) => void; }) => void; }; + /** + * Optional model-output streaming tuning for this runtime. + * + * When `deltaFlushIntervalMs` is 0, each delta is forwarded immediately (no buffering). + * Otherwise deltas are buffered and flushed periodically to reduce message volume. + */ + modelOutputStreaming?: { + deltaFlushIntervalMs?: number | null; + }; }): AcpRuntime { let backend: AcpRuntimeBackend | null = null; let backendPromise: Promise | null = null; @@ -297,6 +321,10 @@ export function createAcpRuntime(params: { const toolNameByCallId = new Map(); const toolCallIdQueue: string[] = []; + const streamDeltaFlushIntervalMs = resolveStreamDeltaFlushIntervalMs( + params.modelOutputStreaming?.deltaFlushIntervalMs, + ); + const clearToolCallCache = () => { toolNameByCallId.clear(); toolCallIdQueue.length = 0; @@ -355,11 +383,69 @@ export function createAcpRuntime(params: { evictToolCallCache(nowMs); }; + // --------------------------------------------------------------------------- + // Streaming debounce buffer: accumulate tiny text deltas (e.g. one word per + // ACP chunk from Copilot) and flush as a single server message periodically. + // This reduces the number of encrypted messages sent through the server and + // avoids race conditions in the UI's async socket handler where out-of-order + // decryption can trigger unnecessary full message refetches. + // --------------------------------------------------------------------------- + let streamDeltaBuffer = ''; + let streamDeltaFlushTimer: ReturnType | null = null; + + const flushStreamDeltaBuffer = () => { + if (streamDeltaFlushTimer) { + clearTimeout(streamDeltaFlushTimer); + streamDeltaFlushTimer = null; + } + const buffered = streamDeltaBuffer; + streamDeltaBuffer = ''; + if (!buffered) return; + if (!turnStreamKey) { + turnStreamKey = `acp:turn:${randomUUID()}`; + } + forwardAcpMessageDelta({ + sendAcp: params.session.sendAgentMessage.bind(params.session), + provider: params.provider, + delta: buffered, + streamMetaKey: 'happierStreamKey', + streamKey: turnStreamKey, + }); + didStreamModelOutputToSession = true; + }; + + const enqueueStreamDelta = (delta: string) => { + if (!delta) return; + + if (streamDeltaFlushIntervalMs === 0) { + if (!turnStreamKey) { + turnStreamKey = `acp:turn:${randomUUID()}`; + } + forwardAcpMessageDelta({ + sendAcp: params.session.sendAgentMessage.bind(params.session), + provider: params.provider, + delta, + streamMetaKey: 'happierStreamKey', + streamKey: turnStreamKey, + }); + didStreamModelOutputToSession = true; + return; + } + + streamDeltaBuffer += delta; + if (!streamDeltaFlushTimer) { + streamDeltaFlushTimer = setTimeout(flushStreamDeltaBuffer, streamDeltaFlushIntervalMs); + streamDeltaFlushTimer.unref?.(); + } + }; + const resetTurnState = () => { accumulatedResponse = ''; isResponseInProgress = false; taskStartedSent = false; turnAborted = false; + // Flush any remaining buffered text before resetting the stream key. + flushStreamDeltaBuffer(); turnStreamKey = null; didStreamModelOutputToSession = false; }; @@ -418,17 +504,7 @@ export function createAcpRuntime(params: { }); if (deltaRaw) { - if (!turnStreamKey) { - turnStreamKey = `acp:turn:${randomUUID()}`; - } - forwardAcpMessageDelta({ - sendAcp: params.session.sendAgentMessage.bind(params.session), - provider: params.provider, - delta: deltaRaw, - streamMetaKey: 'happierStreamKey', - streamKey: turnStreamKey, - }); - didStreamModelOutputToSession = true; + enqueueStreamDelta(deltaRaw); } break; } @@ -686,9 +762,9 @@ export function createAcpRuntime(params: { const availableModelsRaw = payloadRecord?.availableModels; const availableModels = Array.isArray(availableModelsRaw) ? availableModelsRaw - .filter((m: any) => m && typeof m.id === 'string' && typeof m.name === 'string') + .filter((m: any) => m && (typeof m.id === 'string' || typeof m.modelId === 'string') && typeof m.name === 'string') .map((m: any) => ({ - id: String(m.id), + id: String(m.id ?? m.modelId), name: String(m.name), ...(typeof m.description === 'string' ? { description: String(m.description) } : {}), })) @@ -898,6 +974,7 @@ export function createAcpRuntime(params: { async cancel(): Promise { if (!sessionId) return; + flushStreamDeltaBuffer(); const b = await ensureBackend(); try { await b.cancel(sessionId); @@ -1110,6 +1187,8 @@ export function createAcpRuntime(params: { }, flushTurn(): void { + // Flush any remaining buffered streaming text before checking didStreamModelOutputToSession. + flushStreamDeltaBuffer(); turnInFlight = false; stopPendingPump(); params.onThinkingChange(false); diff --git a/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts b/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts index f09880eda..bde949fc1 100644 --- a/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts +++ b/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts @@ -43,6 +43,7 @@ export function createCatalogProviderAcpRuntime { const follower = new JsonlFollower({ filePath, pollIntervalMs: 5, - onJson: (value: unknown) => received.push(value), + onJson: (value: unknown) => { + received.push(value); + }, onError: (error: unknown) => errors.push(error), }); await follower.start(); @@ -88,7 +90,9 @@ describe('JsonlFollower', () => { const follower = new JsonlFollower({ filePath, pollIntervalMs: 5, - onJson: (value: unknown) => received.push(value), + onJson: (value: unknown) => { + received.push(value); + }, }); await follower.start(); @@ -118,7 +122,9 @@ describe('JsonlFollower', () => { filePath, pollIntervalMs: 5, startAtEnd: true, - onJson: (value: unknown) => received.push(value), + onJson: (value: unknown) => { + received.push(value); + }, }); await follower.start(); @@ -147,7 +153,9 @@ describe('JsonlFollower', () => { const follower = new JsonlFollower({ filePath, pollIntervalMs: 5, - onJson: (value: unknown) => received.push(value), + onJson: (value: unknown) => { + received.push(value); + }, onError: (error: unknown) => errors.push(error), }); await follower.start(); diff --git a/apps/cli/src/agent/localControl/jsonlFollower.ts b/apps/cli/src/agent/localControl/jsonlFollower.ts index 7be76b4c7..a6f477377 100644 --- a/apps/cli/src/agent/localControl/jsonlFollower.ts +++ b/apps/cli/src/agent/localControl/jsonlFollower.ts @@ -7,7 +7,7 @@ export class JsonlFollower { private readonly filePath: string; private readonly pollIntervalMs: number; private readonly startAtEnd: boolean; - private readonly onJson: (value: unknown) => void; + private readonly onJson: (value: unknown) => void | Promise; private readonly onError?: (error: unknown) => void; private offsetBytes = 0; @@ -22,7 +22,7 @@ export class JsonlFollower { filePath: string; pollIntervalMs: number; startAtEnd?: boolean; - onJson: (value: unknown) => void; + onJson: (value: unknown) => void | Promise; onError?: (error: unknown) => void; }) { this.filePath = opts.filePath; @@ -150,7 +150,7 @@ export class JsonlFollower { if (!trimmed) continue; try { const parsed = JSON.parse(trimmed) as unknown; - this.onJson(parsed); + await this.onJson(parsed); } catch (e) { this.onError?.(e); } diff --git a/apps/cli/src/agent/reviews/engines/coderabbit/CodeRabbitReviewBackend.ts b/apps/cli/src/agent/reviews/engines/coderabbit/CodeRabbitReviewBackend.ts index 44e4d55aa..040f7478f 100644 --- a/apps/cli/src/agent/reviews/engines/coderabbit/CodeRabbitReviewBackend.ts +++ b/apps/cli/src/agent/reviews/engines/coderabbit/CodeRabbitReviewBackend.ts @@ -109,6 +109,7 @@ export class CodeRabbitReviewBackend implements AgentBackend { cwd: this.cwd, env: buildCodeRabbitEnv({ baseEnv: this.env, homeDir: this.config.homeDir }), stdio: 'pipe', + windowsHide: true, }); childRef.current = child; diff --git a/apps/cli/src/agent/runtime/createBaseSessionForAttach.ts b/apps/cli/src/agent/runtime/createBaseSessionForAttach.ts index da813cc86..615e0af75 100644 --- a/apps/cli/src/agent/runtime/createBaseSessionForAttach.ts +++ b/apps/cli/src/agent/runtime/createBaseSessionForAttach.ts @@ -19,6 +19,7 @@ export async function createBaseSessionForAttach(opts: { return { id: existingSessionId, seq: 0, + encryptionMode: 'e2ee', encryptionKey: attach.encryptionKey, encryptionVariant: attach.encryptionVariant, metadata: opts.metadata, diff --git a/apps/cli/src/agent/runtime/createSessionMetadata.ts b/apps/cli/src/agent/runtime/createSessionMetadata.ts index ceb9bcb04..106c57934 100644 --- a/apps/cli/src/agent/runtime/createSessionMetadata.ts +++ b/apps/cli/src/agent/runtime/createSessionMetadata.ts @@ -11,6 +11,7 @@ import os from 'node:os'; import { resolve } from 'node:path'; import type { AgentId } from '@happier-dev/agents'; +import { buildAcpSessionModeOverrideV1, buildModelOverrideV1 } from '@happier-dev/protocol'; import type { AgentState, Metadata, PermissionMode } from '@/api/types'; import { configuration } from '@/configuration'; @@ -114,20 +115,18 @@ export function createSessionMetadata(opts: CreateSessionMetadataOptions): Sessi ...(typeof opts.permissionModeUpdatedAt === 'number' && { permissionModeUpdatedAt: opts.permissionModeUpdatedAt }), ...(typeof opts.agentModeId === 'string' && opts.agentModeId.trim() ? { - acpSessionModeOverrideV1: { - v: 1, + acpSessionModeOverrideV1: buildAcpSessionModeOverrideV1({ updatedAt: typeof opts.agentModeUpdatedAt === 'number' ? opts.agentModeUpdatedAt : Date.now(), modeId: opts.agentModeId.trim(), - }, + }), } : {}), ...(typeof opts.modelId === 'string' && opts.modelId.trim() ? { - modelOverrideV1: { - v: 1, + modelOverrideV1: buildModelOverrideV1({ updatedAt: typeof opts.modelUpdatedAt === 'number' ? opts.modelUpdatedAt : Date.now(), modelId: opts.modelId.trim(), - }, + }), } : {}), }; diff --git a/apps/cli/src/agent/runtime/initializeBackendRunSession.test.ts b/apps/cli/src/agent/runtime/initializeBackendRunSession.test.ts index 8b22ad4ee..ba61ffbb1 100644 --- a/apps/cli/src/agent/runtime/initializeBackendRunSession.test.ts +++ b/apps/cli/src/agent/runtime/initializeBackendRunSession.test.ts @@ -15,6 +15,7 @@ function createSessionResponse(id: string, metadata: Metadata, state: AgentState return { id, seq: 0, + encryptionMode: 'e2ee', encryptionKey: new Uint8Array([1]), encryptionVariant: 'legacy', metadata, diff --git a/apps/cli/src/agent/runtime/mergeSessionMetadataForStartup.ts b/apps/cli/src/agent/runtime/mergeSessionMetadataForStartup.ts index 07bd20d87..3c3ccc567 100644 --- a/apps/cli/src/agent/runtime/mergeSessionMetadataForStartup.ts +++ b/apps/cli/src/agent/runtime/mergeSessionMetadataForStartup.ts @@ -1,5 +1,6 @@ import type { Metadata, PermissionMode } from '@/api/types'; import { computeMonotonicUpdatedAt } from '@happier-dev/agents'; +import { buildAcpSessionModeOverrideV1, buildModelOverrideV1 } from '@happier-dev/protocol'; export type PermissionModeOverride = { mode: PermissionMode; @@ -304,7 +305,7 @@ export function mergeSessionMetadataForStartup(opts: { mode, }); if (acpMode) { - (merged as any).acpSessionModeOverrideV1 = { v: 1, updatedAt: acpMode.updatedAt, modeId: acpMode.modeId }; + (merged as any).acpSessionModeOverrideV1 = buildAcpSessionModeOverrideV1({ updatedAt: acpMode.updatedAt, modeId: acpMode.modeId }); } else if (mode === 'attach') { // Attach safety: explicitly remove any next-derived override fields. delete (merged as any).acpSessionModeOverrideV1; @@ -318,7 +319,7 @@ export function mergeSessionMetadataForStartup(opts: { mode, }); if (model) { - (merged as any).modelOverrideV1 = { v: 1, updatedAt: model.updatedAt, modelId: model.modelId }; + (merged as any).modelOverrideV1 = buildModelOverrideV1({ updatedAt: model.updatedAt, modelId: model.modelId }); } else if (mode === 'attach') { // Attach safety: explicitly remove any next-derived override fields. delete (merged as any).modelOverrideV1; diff --git a/apps/cli/src/api/api.loopbackUrl.test.ts b/apps/cli/src/api/api.loopbackUrl.test.ts index c8787e1f0..111e97601 100644 --- a/apps/cli/src/api/api.loopbackUrl.test.ts +++ b/apps/cli/src/api/api.loopbackUrl.test.ts @@ -17,6 +17,10 @@ vi.mock('axios', () => ({ isAxiosError: mockIsAxiosError, })); +vi.mock('@/features/serverFeaturesClient', () => ({ + fetchServerFeaturesSnapshot: async () => ({ status: 'unsupported', reason: 'endpoint_missing' }), +})); + describe('ApiClient loopback url resolution', () => { const originalEnv = { homeDir: process.env.HAPPIER_HOME_DIR, diff --git a/apps/cli/src/api/api.plaintextSessionCreate.test.ts b/apps/cli/src/api/api.plaintextSessionCreate.test.ts new file mode 100644 index 000000000..7ac206943 --- /dev/null +++ b/apps/cli/src/api/api.plaintextSessionCreate.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiClient } from './api'; + +const mockPost = vi.fn(); +const mockGet = vi.fn(); + +vi.mock('axios', () => ({ + default: { + post: (...args: any[]) => mockPost(...args), + get: (...args: any[]) => mockGet(...args), + isAxiosError: () => false, + }, + isAxiosError: () => false, +})); + +vi.mock('@/configuration', () => ({ + configuration: { + serverUrl: 'https://api.example.com', + }, +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + }, +})); + +describe('ApiClient.getOrCreateSession (plaintext sessions)', () => { + beforeEach(() => { + mockPost.mockReset(); + mockGet.mockReset(); + vi.unstubAllGlobals(); + }); + + it('sends plaintext metadata when server policy is plaintext_only', async () => { + const credential = { + token: 'token-test', + encryption: { + type: 'legacy' as const, + secret: new Uint8Array(32).fill(7), + }, + }; + + vi.stubGlobal('fetch', vi.fn(async (input: any) => { + const url = typeof input === 'string' ? input : String((input as any)?.url ?? input); + if (url.endsWith('/v1/features')) { + return { + ok: true, + status: 200, + json: async () => ({ + features: {}, + capabilities: { + encryption: { + storagePolicy: 'plaintext_only', + allowAccountOptOut: false, + defaultAccountMode: 'e2ee', + }, + }, + }), + } as any; + } + throw new Error(`Unexpected fetch: ${url}`); + }) as any); + + mockPost.mockImplementation(async (_url: string, body: any) => { + expect(body.encryptionMode).toBe('plain'); + expect(typeof body.metadata).toBe('string'); + expect(body.metadata).toContain('"path":"'); + expect(body.dataEncryptionKey).toBeNull(); + + return { + data: { + session: { + id: 'session-plain', + seq: 1, + encryptionMode: 'plain', + metadata: body.metadata, + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + dataEncryptionKey: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + }, + }; + }); + + const api = await ApiClient.create(credential as any); + const session = await api.getOrCreateSession({ + tag: 'tag-plain', + metadata: { + path: '/tmp', + host: 'localhost', + homeDir: '/home/user', + happyHomeDir: '/home/user/.happy', + happyLibDir: '/home/user/.happy/lib', + happyToolsDir: '/home/user/.happy/tools', + }, + state: null, + }); + + expect(session).not.toBeNull(); + expect(session?.metadata.path).toBe('/tmp'); + }); + + it('uses account mode to decide plaintext session creation when policy is optional', async () => { + const credential = { + token: 'token-test', + encryption: { + type: 'legacy' as const, + secret: new Uint8Array(32).fill(7), + }, + }; + + vi.stubGlobal('fetch', vi.fn(async (input: any) => { + const url = typeof input === 'string' ? input : String((input as any)?.url ?? input); + if (url.endsWith('/v1/features')) { + return { + ok: true, + status: 200, + json: async () => ({ + features: {}, + capabilities: { + encryption: { + storagePolicy: 'optional', + allowAccountOptOut: true, + defaultAccountMode: 'e2ee', + }, + }, + }), + } as any; + } + throw new Error(`Unexpected fetch: ${url}`); + }) as any); + + mockGet.mockResolvedValue({ + status: 200, + data: { mode: 'plain', updatedAt: 1 }, + }); + + mockPost.mockImplementation(async (_url: string, body: any) => { + expect(body.encryptionMode).toBe('plain'); + expect(body.dataEncryptionKey).toBeNull(); + return { + data: { + session: { + id: 'session-plain', + seq: 1, + encryptionMode: 'plain', + metadata: body.metadata, + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + dataEncryptionKey: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + }, + }; + }); + + const api = await ApiClient.create(credential as any); + const session = await api.getOrCreateSession({ + tag: 'tag-plain', + metadata: { + path: '/tmp', + host: 'localhost', + homeDir: '/home/user', + happyHomeDir: '/home/user/.happy', + happyLibDir: '/home/user/.happy/lib', + happyToolsDir: '/home/user/.happy/tools', + }, + state: null, + }); + + expect(session).not.toBeNull(); + expect(session?.metadata.path).toBe('/tmp'); + }); +}); diff --git a/apps/cli/src/api/api.sessionDataEncryptionKey.test.ts b/apps/cli/src/api/api.sessionDataEncryptionKey.test.ts index 90011d4d9..84a447e89 100644 --- a/apps/cli/src/api/api.sessionDataEncryptionKey.test.ts +++ b/apps/cli/src/api/api.sessionDataEncryptionKey.test.ts @@ -30,6 +30,10 @@ vi.mock('@/ui/logger', () => ({ }, })); +vi.mock('@/features/serverFeaturesClient', () => ({ + fetchServerFeaturesSnapshot: async () => ({ status: 'unsupported', reason: 'endpoint_missing' }), +})); + describe('ApiClient.getOrCreateSession (dataEncryptionKey)', () => { beforeEach(() => { mockPost.mockReset(); diff --git a/apps/cli/src/api/api.test.ts b/apps/cli/src/api/api.test.ts index 2c995eb34..0d628d06a 100644 --- a/apps/cli/src/api/api.test.ts +++ b/apps/cli/src/api/api.test.ts @@ -24,6 +24,10 @@ vi.mock('@/ui/logger', () => ({ } })); +vi.mock('@/features/serverFeaturesClient', () => ({ + fetchServerFeaturesSnapshot: async () => ({ status: 'unsupported', reason: 'endpoint_missing' }), +})); + // Mock encryption utilities vi.mock('./encryption', () => ({ decodeBase64: vi.fn((data: string) => data), diff --git a/apps/cli/src/api/api.ts b/apps/cli/src/api/api.ts index 06b7e444d..fccef62e9 100644 --- a/apps/cli/src/api/api.ts +++ b/apps/cli/src/api/api.ts @@ -27,6 +27,7 @@ import type { SealedConnectedServiceCredentialV1, SealedConnectedServiceQuotaSnapshotV1, } from '@happier-dev/protocol'; +import { resolveSessionCreateEncryptionMode } from '@/api/session/resolveSessionCreateEncryptionMode'; export class MachineIdConflictError extends Error { readonly machineId: string; @@ -84,6 +85,14 @@ export class ApiClient { const { encryptionKey, encryptionVariant, dataEncryptionKey } = resolveSessionEncryptionContext(this.credential); const sessionsUrl = `${resolveServerHttpBaseUrl()}/v1/sessions`; + const serverBaseUrl = resolveServerHttpBaseUrl(); + const { desiredSessionEncryptionMode, serverSupportsFeatureSnapshot } = await resolveSessionCreateEncryptionMode({ + token: this.credential.token, + serverBaseUrl, + featuresTimeoutMs: 800, + accountTimeoutMs: 10_000, + }); + const resolvePositiveIntEnv = (raw: string | undefined, fallback: number, bounds: { min: number; max: number }): number => { const value = (raw ?? '').trim(); if (!value) return fallback; @@ -113,13 +122,28 @@ export class ApiClient { // Create session (retry transient 5xx, but do not enter offline mode for 5xx). for (let attempt = 1; attempt <= retryMaxAttempts; attempt += 1) { try { + const metadataPayload = + desiredSessionEncryptionMode === 'plain' + ? JSON.stringify(opts.metadata) + : encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.metadata)); + const agentStatePayload = + desiredSessionEncryptionMode === 'plain' + ? (opts.state ? JSON.stringify(opts.state) : null) + : (opts.state ? encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.state)) : null); + const response = await axios.post( sessionsUrl, { tag: opts.tag, - metadata: encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.metadata)), - agentState: opts.state ? encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.state)) : null, - dataEncryptionKey: dataEncryptionKey ? encodeBase64(dataEncryptionKey) : null, + metadata: metadataPayload, + agentState: agentStatePayload, + dataEncryptionKey: + desiredSessionEncryptionMode === 'plain' + ? null + : dataEncryptionKey + ? encodeBase64(dataEncryptionKey) + : null, + ...(serverSupportsFeatureSnapshot ? { encryptionMode: desiredSessionEncryptionMode } : {}), }, { headers: { @@ -133,10 +157,13 @@ export class ApiClient { logger.debug(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`) let raw = response.data.session; + const sessionEncryptionMode: 'e2ee' | 'plain' = + (raw as any)?.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + // Prefer the session's published data key, but keep backward compatibility with // older sessions that have no dataEncryptionKey (machineKey-as-session-key fallback). let sessionEncryptionKey = encryptionKey; - if (this.credential.encryption.type === 'dataKey') { + if (sessionEncryptionMode === 'e2ee' && this.credential.encryption.type === 'dataKey') { const serverEncryptedDataKeyRaw = (raw as any).dataEncryptionKey; const opened = openSessionDataEncryptionKey({ credential: this.credential, @@ -154,9 +181,18 @@ export class ApiClient { let session: Session = { id: raw.id, seq: raw.seq, - metadata: decrypt(sessionEncryptionKey, encryptionVariant, decodeBase64(raw.metadata)), + encryptionMode: sessionEncryptionMode, + metadata: + sessionEncryptionMode === 'plain' + ? JSON.parse(String(raw.metadata ?? 'null')) + : decrypt(sessionEncryptionKey, encryptionVariant, decodeBase64(raw.metadata)), metadataVersion: raw.metadataVersion, - agentState: raw.agentState ? decrypt(sessionEncryptionKey, encryptionVariant, decodeBase64(raw.agentState)) : null, + agentState: + !raw.agentState + ? null + : sessionEncryptionMode === 'plain' + ? JSON.parse(String(raw.agentState)) + : decrypt(sessionEncryptionKey, encryptionVariant, decodeBase64(raw.agentState)), agentStateVersion: raw.agentStateVersion, encryptionKey: sessionEncryptionKey, encryptionVariant: encryptionVariant diff --git a/apps/cli/src/api/machine/rpcHandlers.test.ts b/apps/cli/src/api/machine/rpcHandlers.test.ts index 6fd898c2d..3d719c88f 100644 --- a/apps/cli/src/api/machine/rpcHandlers.test.ts +++ b/apps/cli/src/api/machine/rpcHandlers.test.ts @@ -287,20 +287,34 @@ describe('registerMachineRpcHandlers', () => { const encryptedTwo = encodeBase64( encrypt(sessionEncryptionKey, 'dataKey', { role: 'agent', content: { type: 'text', text: 'two' } }), ); - const encryptedThree = encodeBase64( - encrypt(sessionEncryptionKey, 'dataKey', { role: 'user', content: { type: 'text', text: 'three' } }), - ); - - const getSpy = vi.spyOn(axios, 'get'); - getSpy - .mockResolvedValueOnce({ - status: 200, - data: { session: { id: 'sess_prev', dataEncryptionKey: encodeBase64(envelope) } }, - } as any) - .mockResolvedValueOnce({ - status: 200, - data: { - messages: [ + const encryptedThree = encodeBase64( + encrypt(sessionEncryptionKey, 'dataKey', { role: 'user', content: { type: 'text', text: 'three' } }), + ); + + const getSpy = vi.spyOn(axios, 'get'); + getSpy + .mockResolvedValueOnce({ + status: 200, + data: { + session: { + id: 'sess_prev', + seq: 0, + createdAt: 1, + updatedAt: 2, + active: true, + activeAt: 2, + metadata: '', + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + dataEncryptionKey: encodeBase64(envelope), + }, + }, + } as any) + .mockResolvedValueOnce({ + status: 200, + data: { + messages: [ { createdAt: 1, content: { t: 'encrypted', c: encryptedOne } }, { createdAt: 2, content: { t: 'encrypted', c: encryptedTwo } }, { createdAt: 3, content: { t: 'encrypted', c: encryptedThree } }, @@ -430,16 +444,30 @@ describe('registerMachineRpcHandlers', () => { encrypt(sessionEncryptionKey, 'dataKey', { role: 'user', content: { type: 'text', text: longText } }), ); - const getSpy = vi.spyOn(axios, 'get'); - getSpy - .mockResolvedValueOnce({ - status: 200, - data: { session: { id: 'sess_prev', dataEncryptionKey: encodeBase64(envelope) } }, - } as any) - .mockResolvedValueOnce({ - status: 200, - data: { - messages: [ + const getSpy = vi.spyOn(axios, 'get'); + getSpy + .mockResolvedValueOnce({ + status: 200, + data: { + session: { + id: 'sess_prev', + seq: 0, + createdAt: 1, + updatedAt: 2, + active: true, + activeAt: 2, + metadata: '', + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + dataEncryptionKey: encodeBase64(envelope), + }, + }, + } as any) + .mockResolvedValueOnce({ + status: 200, + data: { + messages: [ { seq: 1, createdAt: 1, content: { t: 'encrypted', c: encryptedOne } }, ], }, diff --git a/apps/cli/src/api/session/fetchEncryptedTranscriptWindow.test.ts b/apps/cli/src/api/session/fetchEncryptedTranscriptWindow.test.ts index 23dc8e3f7..5be9da97e 100644 --- a/apps/cli/src/api/session/fetchEncryptedTranscriptWindow.test.ts +++ b/apps/cli/src/api/session/fetchEncryptedTranscriptWindow.test.ts @@ -70,6 +70,30 @@ describe('fetchEncryptedTranscriptWindow', () => { expect(opts.params).toEqual({ limit: 2 }); }); + it('parses plaintext transcript rows (no filtering)', async () => { + mockGet.mockResolvedValue({ + status: 200, + data: { + messages: [ + { + seq: 1, + createdAt: 2, + content: { t: 'plain', v: { role: 'user', content: { type: 'text', text: 'hello' } } }, + }, + ], + }, + }); + + const rows = await fetchEncryptedTranscriptPageLatest({ + token: 't', + sessionId: 'sess_plain', + limit: 10, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]!.content.t).toBe('plain'); + }); + it('computes afterSeq and limit for a bounded range fetch', async () => { mockGet.mockResolvedValue({ status: 200, diff --git a/apps/cli/src/api/session/fetchEncryptedTranscriptWindow.ts b/apps/cli/src/api/session/fetchEncryptedTranscriptWindow.ts index 44f1e5c36..4c0c05798 100644 --- a/apps/cli/src/api/session/fetchEncryptedTranscriptWindow.ts +++ b/apps/cli/src/api/session/fetchEncryptedTranscriptWindow.ts @@ -2,11 +2,12 @@ import axios from 'axios'; import { configuration } from '@/configuration'; import { resolveLoopbackHttpUrl } from '@/api/client/loopbackUrl'; +import { SessionMessageContentSchema, type SessionMessageContent } from '../types'; -export type EncryptedTranscriptRow = Readonly<{ +export type TranscriptRow = Readonly<{ seq: number; createdAt: number; - content: { t: 'encrypted'; c: string }; + content: SessionMessageContent; id?: string; localId?: string | null; }>; @@ -20,20 +21,19 @@ type RawTranscriptRow = Readonly<{ }>; export type FetchEncryptedTranscriptRangeResult = - | Readonly<{ ok: true; rows: EncryptedTranscriptRow[] }> + | Readonly<{ ok: true; rows: TranscriptRow[] }> | Readonly<{ ok: false; errorCode: 'window_too_large'; maxMessages: number; requestedMessages: number }>; -function parseEncryptedTranscriptRows(raw: unknown): EncryptedTranscriptRow[] { +function parseTranscriptRows(raw: unknown): TranscriptRow[] { if (!Array.isArray(raw)) return []; - const out: EncryptedTranscriptRow[] = []; + const out: TranscriptRow[] = []; for (const entry of raw as RawTranscriptRow[]) { const seq = typeof entry?.seq === 'number' && Number.isFinite(entry.seq) ? Math.trunc(entry.seq) : null; const createdAt = typeof entry?.createdAt === 'number' && Number.isFinite(entry.createdAt) ? Math.trunc(entry.createdAt) : null; - const content = entry?.content as any; - const ciphertext = content && typeof content === 'object' && content.t === 'encrypted' ? content.c : null; if (seq === null || createdAt === null) continue; - if (typeof ciphertext !== 'string' || ciphertext.trim().length === 0) continue; + const parsedContent = SessionMessageContentSchema.safeParse(entry?.content); + if (!parsedContent.success) continue; const id = typeof entry?.id === 'string' ? entry.id : undefined; const localId = typeof entry?.localId === 'string' ? entry.localId : null; out.push({ @@ -41,7 +41,7 @@ function parseEncryptedTranscriptRows(raw: unknown): EncryptedTranscriptRow[] { localId, seq, createdAt, - content: { t: 'encrypted', c: ciphertext }, + content: parsedContent.data, }); } return out; @@ -52,7 +52,7 @@ export async function fetchEncryptedTranscriptPageAfterSeq(params: Readonly<{ sessionId: string; afterSeq: number; limit: number; -}>): Promise { +}>): Promise { const serverUrl = resolveLoopbackHttpUrl(configuration.serverUrl).replace(/\/+$/, ''); const response = await axios.get(`${serverUrl}/v1/sessions/${params.sessionId}/messages`, { headers: { @@ -71,14 +71,14 @@ export async function fetchEncryptedTranscriptPageAfterSeq(params: Readonly<{ throw new Error(`Unexpected status from /v1/sessions/:id/messages: ${response.status}`); } - return parseEncryptedTranscriptRows((response.data as any)?.messages); + return parseTranscriptRows((response.data as any)?.messages); } export async function fetchEncryptedTranscriptPageLatest(params: Readonly<{ token: string; sessionId: string; limit: number; -}>): Promise { +}>): Promise { const serverUrl = resolveLoopbackHttpUrl(configuration.serverUrl).replace(/\/+$/, ''); const response = await axios.get(`${serverUrl}/v1/sessions/${params.sessionId}/messages`, { headers: { @@ -97,7 +97,7 @@ export async function fetchEncryptedTranscriptPageLatest(params: Readonly<{ throw new Error(`Unexpected status from /v1/sessions/:id/messages: ${response.status}`); } - return parseEncryptedTranscriptRows((response.data as any)?.messages); + return parseTranscriptRows((response.data as any)?.messages); } export async function fetchEncryptedTranscriptRange(params: Readonly<{ diff --git a/apps/cli/src/api/session/resolveSessionCreateEncryptionMode.ts b/apps/cli/src/api/session/resolveSessionCreateEncryptionMode.ts new file mode 100644 index 000000000..dc2ce230d --- /dev/null +++ b/apps/cli/src/api/session/resolveSessionCreateEncryptionMode.ts @@ -0,0 +1,61 @@ +import axios from 'axios'; + +import { AccountEncryptionModeResponseSchema } from '@happier-dev/protocol'; + +import { fetchServerFeaturesSnapshot } from '@/features/serverFeaturesClient'; + +export type DesiredSessionCreateEncryptionModeResult = Readonly<{ + desiredSessionEncryptionMode: 'e2ee' | 'plain'; + serverSupportsFeatureSnapshot: boolean; + storagePolicy: 'required_e2ee' | 'optional' | 'plaintext_only'; +}>; + +export async function resolveSessionCreateEncryptionMode(params: Readonly<{ + token: string; + serverBaseUrl: string; + featuresTimeoutMs?: number; + accountTimeoutMs?: number; +}>): Promise { + const featuresTimeoutMs = typeof params.featuresTimeoutMs === 'number' && params.featuresTimeoutMs > 0 ? params.featuresTimeoutMs : 800; + const accountTimeoutMs = typeof params.accountTimeoutMs === 'number' && params.accountTimeoutMs > 0 ? params.accountTimeoutMs : 10_000; + + const featuresSnapshot = await fetchServerFeaturesSnapshot({ serverUrl: params.serverBaseUrl, timeoutMs: featuresTimeoutMs }); + const serverSupportsFeatureSnapshot = featuresSnapshot.status === 'ready'; + const storagePolicy: 'required_e2ee' | 'optional' | 'plaintext_only' = + featuresSnapshot.status === 'ready' + ? featuresSnapshot.features.capabilities.encryption.storagePolicy + : 'required_e2ee'; + + if (storagePolicy === 'plaintext_only') { + return { desiredSessionEncryptionMode: 'plain', serverSupportsFeatureSnapshot, storagePolicy }; + } + if (storagePolicy !== 'optional') { + return { desiredSessionEncryptionMode: 'e2ee', serverSupportsFeatureSnapshot, storagePolicy }; + } + + // storagePolicy === 'optional': follow the account's stored preference (fail-closed to e2ee). + try { + const response = await axios.get(`${params.serverBaseUrl.replace(/\/+$/, '')}/v1/account/encryption`, { + headers: { + Authorization: `Bearer ${params.token}`, + 'Content-Type': 'application/json', + }, + timeout: accountTimeoutMs, + validateStatus: () => true, + }); + if (response.status !== 200) { + return { desiredSessionEncryptionMode: 'e2ee', serverSupportsFeatureSnapshot, storagePolicy }; + } + const parsed = AccountEncryptionModeResponseSchema.safeParse(response.data); + if (!parsed.success) { + return { desiredSessionEncryptionMode: 'e2ee', serverSupportsFeatureSnapshot, storagePolicy }; + } + return { + desiredSessionEncryptionMode: parsed.data.mode, + serverSupportsFeatureSnapshot, + storagePolicy, + }; + } catch { + return { desiredSessionEncryptionMode: 'e2ee', serverSupportsFeatureSnapshot, storagePolicy }; + } +} diff --git a/apps/cli/src/api/session/sessionClient.echoToSender.test.ts b/apps/cli/src/api/session/sessionClient.echoToSender.test.ts new file mode 100644 index 000000000..67468e8d0 --- /dev/null +++ b/apps/cli/src/api/session/sessionClient.echoToSender.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Session } from '@/api/types'; + +type EmitWithAckCall = { event: string; payload: unknown }; + +type SocketStub = { + socket: { + id: string; + connected: boolean; + on: (event: string, handler: (...args: any[]) => void) => void; + off: (event: string, handler?: (...args: any[]) => void) => void; + close: () => void; + connect: () => void; + disconnect: () => void; + emit: (...args: any[]) => void; + timeout: (ms: number) => any; + emitWithAck: (event: string, payload: unknown) => Promise; + }; + calls: { emitWithAck: EmitWithAckCall[] }; +}; + +function createSocketStub(opts: { emitWithAckResult: unknown }): SocketStub { + const calls: { emitWithAck: EmitWithAckCall[] } = { emitWithAck: [] }; + + const socket: SocketStub['socket'] = { + id: 'sock-1', + connected: true, + on: vi.fn(), + off: vi.fn(), + close: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + emit: vi.fn(), + timeout: vi.fn(function timeout() { + return socket; + }), + emitWithAck: vi.fn(async (event: string, payload: unknown) => { + calls.emitWithAck.push({ event, payload }); + return opts.emitWithAckResult; + }), + }; + + return { socket, calls }; +} + +let sessionSocketStub: SocketStub | null = null; +let userSocketStub: SocketStub | null = null; + +vi.mock('./sockets', () => ({ + createSessionScopedSocket: () => { + if (!sessionSocketStub) throw new Error('Missing session socket stub'); + return sessionSocketStub.socket as any; + }, + createUserScopedSocket: () => { + if (!userSocketStub) throw new Error('Missing user socket stub'); + return userSocketStub.socket as any; + }, +})); + +describe('ApiSessionClient socket message commits', () => { + it('requests sender echo so broadcasts can clear pending localIds', async () => { + vi.resetModules(); + sessionSocketStub = createSocketStub({ emitWithAckResult: { ok: true, id: 'm1', seq: 1, localId: 'l1' } }); + userSocketStub = createSocketStub({ emitWithAckResult: { ok: true } }); + + const { ApiSessionClient } = await import('./sessionClient'); + + const session: Session = { + id: 's1', + seq: 0, + encryptionMode: 'plain', + encryptionKey: new Uint8Array([1, 2, 3]), + encryptionVariant: 'legacy', + metadata: { + path: '/tmp', + host: 'test', + homeDir: '/home/test', + happyHomeDir: '/home/test/.happier', + happyLibDir: '/home/test/.happier/lib', + happyToolsDir: '/home/test/.happier/tools', + }, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + }; + + const client = new ApiSessionClient('tok', session); + await client.sendUserTextMessageCommitted('hello', { localId: 'l1' }); + + expect(sessionSocketStub.calls.emitWithAck.length).toBe(1); + expect(sessionSocketStub.calls.emitWithAck[0]!.event).toBe('message'); + expect(sessionSocketStub.calls.emitWithAck[0]!.payload).toMatchObject({ echoToSender: true }); + }); +}); diff --git a/apps/cli/src/api/session/sessionClient.ts b/apps/cli/src/api/session/sessionClient.ts index ddb8749a5..2fc5b0b6a 100644 --- a/apps/cli/src/api/session/sessionClient.ts +++ b/apps/cli/src/api/session/sessionClient.ts @@ -44,6 +44,7 @@ import { handleSessionStateUpdate } from './sessionStateUpdateHandling'; import type { ACPMessageData, ACPProvider, SessionEventMessage } from './sessionMessageTypes'; import { consumeDaemonInitialPromptFromEnv } from '@/agent/runtime/daemonInitialPrompt'; import { resolveCliFeatureDecision } from '@/features/featureDecisionService'; +import { createKeyedSingleFlightScheduler, type KeyedSingleFlightScheduler } from './transcriptRecoveryScheduler'; export class ApiSessionClient extends EventEmitter { private static readonly STARTUP_MESSAGE_CATCH_UP_RETRY_DELAYS_MS = [250, 1_000, 2_500] as const; @@ -63,6 +64,7 @@ export class ApiSessionClient extends EventEmitter { private metadataLock = new AsyncLock(); private encryptionKey: Uint8Array; private encryptionVariant: 'legacy' | 'dataKey'; + private readonly sessionEncryptionMode: 'e2ee' | 'plain'; private disconnectedSendLogged = false; private readonly pendingMaterializedLocalIds = new Set(); private readonly pendingQueueMaterializedLocalIds = new Set(); @@ -85,6 +87,8 @@ export class ApiSessionClient extends EventEmitter { private startupMessageCatchUpRetryIndex = 0; private startupMessageCatchUpRetryTimer: ReturnType | null = null; private readonly startedByDaemonProcess: boolean; + private readonly materializationRecoveryScheduler: KeyedSingleFlightScheduler; + private readonly transcriptRecoveryErrorStateByLocalId = new Map(); /** * Returns the latest known agentState (may be stale if socket is disconnected). @@ -113,7 +117,12 @@ export class ApiSessionClient extends EventEmitter { this.agentStateVersion = session.agentStateVersion; this.encryptionKey = session.encryptionKey; this.encryptionVariant = session.encryptionVariant; + this.sessionEncryptionMode = session.encryptionMode === 'plain' ? 'plain' : 'e2ee'; this.daemonInitialPrompt = consumeDaemonInitialPromptFromEnv(); + this.materializationRecoveryScheduler = createKeyedSingleFlightScheduler({ + delayMs: configuration.transcriptRecoveryDelayMs, + maxConcurrent: configuration.transcriptRecoveryMaxConcurrent, + }); this.startedByDaemonProcess = (() => { const idx = process.argv.indexOf('--started-by'); if (idx < 0) return false; @@ -257,6 +266,28 @@ export class ApiSessionClient extends EventEmitter { this.socket.connect(); } + private debugTranscriptRecoveryFetchError(localId: string, error: unknown): void { + const now = Date.now(); + const throttleMs = configuration.transcriptRecoveryErrorLogThrottleMs; + const state = this.transcriptRecoveryErrorStateByLocalId.get(localId) ?? { lastLoggedAt: 0, suppressed: 0 }; + + if (state.lastLoggedAt === 0 || now - state.lastLoggedAt >= throttleMs) { + const suppressed = state.suppressed; + state.lastLoggedAt = now; + state.suppressed = 0; + this.transcriptRecoveryErrorStateByLocalId.set(localId, state); + logger.debug('[API] Failed to fetch transcript messages for pending-queue recovery', { + localId, + suppressedSinceLastLog: suppressed, + error, + }); + return; + } + + state.suppressed += 1; + this.transcriptRecoveryErrorStateByLocalId.set(localId, state); + } + private syncSessionSnapshotFromServer(opts: { reason: 'connect' | 'waitForMetadataUpdate' }): Promise { if (this.closed) return Promise.resolve(); if (this.snapshotSyncInFlight) return this.snapshotSyncInFlight; @@ -341,6 +372,8 @@ export class ApiSessionClient extends EventEmitter { private deleteMaterializedLocalId(localId: string): void { this.pendingMaterializedLocalIds.delete(localId); this.pendingQueueMaterializedLocalIds.delete(localId); + this.materializationRecoveryScheduler.cancel(localId); + this.transcriptRecoveryErrorStateByLocalId.delete(localId); this.maybeScheduleUserSocketDisconnect(); } @@ -524,7 +557,7 @@ export class ApiSessionClient extends EventEmitter { localId, maxWaitMs: opts?.maxWaitMs, onError: (error) => { - logger.debug('[API] Failed to fetch transcript messages for pending-queue recovery', { error }); + this.debugTranscriptRecoveryFetchError(localId, error); }, }); if (!found) return false; @@ -555,12 +588,10 @@ export class ApiSessionClient extends EventEmitter { private scheduleMaterializationRecovery(localId: string): void { // Belt-and-suspenders: if we fail to observe the socket broadcast for a committed transcript row, // recover by scanning the transcript and re-injecting the message into the normal update pipeline. - const delayMs = 500; - const timer = setTimeout(() => { + this.materializationRecoveryScheduler.schedule(localId, async () => { if (!this.hasMaterializedLocalId(localId)) return; - void this.recoverMaterializedLocalId(localId, { maxWaitMs: 7_500 }); - }, delayMs); - timer.unref?.(); + await this.recoverMaterializedLocalId(localId, { maxWaitMs: configuration.transcriptRecoveryMaxWaitMs }); + }); } onUserMessage(callback: (data: UserMessage) => void) { @@ -748,8 +779,8 @@ export class ApiSessionClient extends EventEmitter { }); } - private async commitEncryptedSessionMessage( - params: { encryptedMessage: string; localId: string; requireCommit: boolean }, + private async commitSessionMessage( + params: { message: string | { t: 'plain'; v: unknown }; localId: string; requireCommit: boolean }, ): Promise { const localId = params.localId; if (localId.length === 0) { @@ -766,11 +797,12 @@ export class ApiSessionClient extends EventEmitter { this.pendingMaterializedLocalIds.add(localId); this.socket.emit('message', { sid: this.sessionId, - message: params.encryptedMessage, + message: params.message, localId, + echoToSender: true, }); this.scheduleMaterializationRecovery(localId); - this.scheduleCommitRetry({ encryptedMessage: params.encryptedMessage, localId }); + this.scheduleCommitRetry({ message: params.message, localId }); return; } @@ -781,8 +813,9 @@ export class ApiSessionClient extends EventEmitter { .timeout(7_500) .emitWithAck('message', { sid: this.sessionId, - message: params.encryptedMessage, + message: params.message, localId, + echoToSender: true, }) as unknown; const parsed = MessageAckResponseSchema.safeParse(raw); @@ -818,10 +851,10 @@ export class ApiSessionClient extends EventEmitter { } this.scheduleMaterializationRecovery(localId); - this.scheduleCommitRetry({ encryptedMessage: params.encryptedMessage, localId }); + this.scheduleCommitRetry({ message: params.message, localId }); } - private scheduleCommitRetry(params: { encryptedMessage: string; localId: string }): void { + private scheduleCommitRetry(params: { message: string | { t: 'plain'; v: unknown }; localId: string }): void { const localId = params.localId; if (!localId) return; if (!this.pendingMaterializedLocalIds.has(localId)) return; @@ -839,8 +872,8 @@ export class ApiSessionClient extends EventEmitter { this.pendingCommitRetryAttemptsByLocalId.delete(localId); return; } - void this.commitEncryptedSessionMessage({ - encryptedMessage: params.encryptedMessage, + void this.commitSessionMessage({ + message: params.message, localId, requireCommit: false, }).catch(() => { @@ -854,13 +887,20 @@ export class ApiSessionClient extends EventEmitter { return encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content as any)); } - private commitEncryptedSessionMessageBestEffort(params: { - encryptedMessage: string; + private buildOutboundSessionMessagePayload(content: unknown): string | { t: 'plain'; v: unknown } { + if (this.sessionEncryptionMode === 'plain') { + return { t: 'plain', v: content }; + } + return this.encryptSessionContent(content); + } + + private commitSessionMessageBestEffort(params: { + message: string | { t: 'plain'; v: unknown }; localId: string; logErrorMessage: string; }): void { - void this.commitEncryptedSessionMessage({ - encryptedMessage: params.encryptedMessage, + void this.commitSessionMessage({ + message: params.message, localId: params.localId, requireCommit: false, }).catch((error) => { @@ -919,10 +959,10 @@ export class ApiSessionClient extends EventEmitter { this.logSendWhileDisconnected('Claude session message', { type: body.type }); - const encrypted = this.encryptSessionContent(content); + const payload = this.buildOutboundSessionMessagePayload(content); const localId = randomUUID(); - this.commitEncryptedSessionMessageBestEffort({ - encryptedMessage: encrypted, + this.commitSessionMessageBestEffort({ + message: payload, localId, logErrorMessage: '[SOCKET] Failed to commit Claude session message (non-fatal)', }); @@ -971,10 +1011,10 @@ export class ApiSessionClient extends EventEmitter { this.logSendWhileDisconnected('Codex message', { type: normalizedBody?.type }); - const encrypted = this.encryptSessionContent(content); + const payload = this.buildOutboundSessionMessagePayload(content); const localId = randomUUID(); - this.commitEncryptedSessionMessageBestEffort({ - encryptedMessage: encrypted, + this.commitSessionMessageBestEffort({ + message: payload, localId, logErrorMessage: '[SOCKET] Failed to commit Codex message (non-fatal)', }); @@ -1052,9 +1092,9 @@ export class ApiSessionClient extends EventEmitter { logger.debug(`[SOCKET] Sending ACP message from ${provider}:`, { type: normalizedBody.type, hasMessage: 'message' in normalizedBody }); this.logSendWhileDisconnected(`${provider} ACP message`, { type: normalizedBody.type }); - const encrypted = this.encryptSessionContent(content); - this.commitEncryptedSessionMessageBestEffort({ - encryptedMessage: encrypted, + const payload = this.buildOutboundSessionMessagePayload(content); + this.commitSessionMessageBestEffort({ + message: payload, localId, logErrorMessage: '[SOCKET] Failed to commit agent message (non-fatal)', }); @@ -1080,10 +1120,10 @@ export class ApiSessionClient extends EventEmitter { const content = this.buildUserTextMessageContent(text, opts?.meta); this.logSendWhileDisconnected('User text message', { length: text.length }); - const encrypted = this.encryptSessionContent(content); + const payload = this.buildOutboundSessionMessagePayload(content); const localId = typeof opts?.localId === 'string' && opts.localId.length > 0 ? opts.localId : randomUUID(); - this.commitEncryptedSessionMessageBestEffort({ - encryptedMessage: encrypted, + this.commitSessionMessageBestEffort({ + message: payload, localId, logErrorMessage: '[SOCKET] Failed to commit user message (non-fatal)', }); @@ -1094,8 +1134,8 @@ export class ApiSessionClient extends EventEmitter { opts: { localId: string; meta?: Record }, ): Promise { const content = this.buildUserTextMessageContent(text, opts.meta); - const encrypted = this.encryptSessionContent(content); - await this.commitEncryptedSessionMessage({ encryptedMessage: encrypted, localId: opts.localId, requireCommit: true }); + const payload = this.buildOutboundSessionMessagePayload(content); + await this.commitSessionMessage({ message: payload, localId: opts.localId, requireCommit: true }); } async sendAgentMessageCommitted( @@ -1114,8 +1154,8 @@ export class ApiSessionClient extends EventEmitter { recordAcpToolTraceEventIfNeeded({ sessionId: this.sessionId, provider, body: normalizedBody, localId }); } - const encrypted = this.encryptSessionContent(content); - await this.commitEncryptedSessionMessage({ encryptedMessage: encrypted, localId, requireCommit: true }); + const payload = this.buildOutboundSessionMessagePayload(content); + await this.commitSessionMessage({ message: payload, localId, requireCommit: true }); } async fetchRecentTranscriptTextItemsForAcpImport(opts?: { take?: number }): Promise> { @@ -1150,10 +1190,10 @@ export class ApiSessionClient extends EventEmitter { this.logSendWhileDisconnected('session event', { eventType: event.type }); - const encrypted = this.encryptSessionContent(content); + const payload = this.buildOutboundSessionMessagePayload(content); const localId = randomUUID(); - this.commitEncryptedSessionMessageBestEffort({ - encryptedMessage: encrypted, + this.commitSessionMessageBestEffort({ + message: payload, localId, logErrorMessage: '[SOCKET] Failed to commit session event (non-fatal)', }); @@ -1237,6 +1277,7 @@ export class ApiSessionClient extends EventEmitter { await updateSessionMetadataWithAck({ socket: this.socket as any, sessionId: this.sessionId, + sessionEncryptionMode: this.sessionEncryptionMode, encryptionKey: this.encryptionKey, encryptionVariant: this.encryptionVariant, getMetadata: () => this.metadata, @@ -1263,6 +1304,7 @@ export class ApiSessionClient extends EventEmitter { await updateSessionAgentStateWithAck({ socket: this.socket as any, sessionId: this.sessionId, + sessionEncryptionMode: this.sessionEncryptionMode, encryptionKey: this.encryptionKey, encryptionVariant: this.encryptionVariant, getAgentState: () => this.agentState, diff --git a/apps/cli/src/api/session/sessionMessageCatchUp.plain.test.ts b/apps/cli/src/api/session/sessionMessageCatchUp.plain.test.ts new file mode 100644 index 000000000..a548598d7 --- /dev/null +++ b/apps/cli/src/api/session/sessionMessageCatchUp.plain.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/configuration', () => ({ + configuration: { serverUrl: 'http://example.test' }, +})); + +vi.mock('../client/loopbackUrl', () => ({ + resolveLoopbackHttpUrl: (url: string) => url, +})); + +import axios from 'axios'; + +import { catchUpSessionMessagesAfterSeq } from './sessionMessageCatchUp'; + +describe('sessionMessageCatchUp (plaintext envelopes)', () => { + it('emits new-message updates for plaintext transcript messages', async () => { + const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ + data: { + messages: [ + { + id: 'm1', + seq: 12, + localId: 'l1', + createdAt: 123, + content: { t: 'plain', v: { role: 'user', content: { type: 'text', text: 'hello' } } }, + }, + ], + }, + } as any); + + const updates: any[] = []; + await catchUpSessionMessagesAfterSeq({ + token: 't', + sessionId: 's1', + afterSeq: 10, + onUpdate: (u) => updates.push(u), + }); + + expect(getSpy).toHaveBeenCalledTimes(1); + expect(updates).toHaveLength(1); + expect(updates[0]?.body?.t).toBe('new-message'); + expect(updates[0]?.body?.message?.content?.t).toBe('plain'); + }); +}); + diff --git a/apps/cli/src/api/session/sessionMessageCatchUp.ts b/apps/cli/src/api/session/sessionMessageCatchUp.ts index ff1772ed9..59b8eebe9 100644 --- a/apps/cli/src/api/session/sessionMessageCatchUp.ts +++ b/apps/cli/src/api/session/sessionMessageCatchUp.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { configuration } from '@/configuration'; -import type { Update } from '../types'; +import { SessionMessageContentSchema, type Update } from '../types'; import { resolveLoopbackHttpUrl } from '../client/loopbackUrl'; export async function catchUpSessionMessagesAfterSeq(params: { @@ -37,7 +37,8 @@ export async function catchUpSessionMessagesAfterSeq(params: { const seq = (msg as any).seq; const content = (msg as any).content; if (typeof id !== 'string' || typeof seq !== 'number') continue; - if (!content || content.t !== 'encrypted' || typeof content.c !== 'string') continue; + const parsedContent = SessionMessageContentSchema.safeParse(content); + if (!parsedContent.success) continue; const localId = typeof (msg as any).localId === 'string' ? (msg as any).localId : undefined; const createdAt = typeof (msg as any).createdAt === 'number' ? (msg as any).createdAt : Date.now(); @@ -53,7 +54,7 @@ export async function catchUpSessionMessagesAfterSeq(params: { id, seq, ...(localId ? { localId } : {}), - content, + content: parsedContent.data, }, }, } as Update; diff --git a/apps/cli/src/api/session/sessionNewMessageUpdate.ts b/apps/cli/src/api/session/sessionNewMessageUpdate.ts index 824b89977..5cd3ce6ff 100644 --- a/apps/cli/src/api/session/sessionNewMessageUpdate.ts +++ b/apps/cli/src/api/session/sessionNewMessageUpdate.ts @@ -57,16 +57,20 @@ export function handleSessionNewMessageUpdate(params: { } let body: unknown; - try { - body = decrypt(params.encryptionKey, params.encryptionVariant, decodeBase64(parsedContent.data.c)); - } catch (error) { - params.debug('[SOCKET] [UPDATE] Failed to decrypt new-message payload', { - error, - messageId: typeof messageId === 'string' ? messageId : null, - localId, - msgSeq: typeof msgSeq === 'number' && Number.isFinite(msgSeq) ? msgSeq : null, - }); - return { handled: true, lastObservedMessageSeq: nextLastObservedMessageSeq }; + if (parsedContent.data.t === 'plain') { + body = parsedContent.data.v; + } else { + try { + body = decrypt(params.encryptionKey, params.encryptionVariant, decodeBase64(parsedContent.data.c)); + } catch (error) { + params.debug('[SOCKET] [UPDATE] Failed to decrypt new-message payload', { + error, + messageId: typeof messageId === 'string' ? messageId : null, + localId, + msgSeq: typeof msgSeq === 'number' && Number.isFinite(msgSeq) ? msgSeq : null, + }); + return { handled: true, lastObservedMessageSeq: nextLastObservedMessageSeq }; + } } const bodyWithLocalId = params.update.body.message.localId === undefined diff --git a/apps/cli/src/api/session/snapshotSync.test.ts b/apps/cli/src/api/session/snapshotSync.test.ts index 48650c080..4ef6d9ad2 100644 --- a/apps/cli/src/api/session/snapshotSync.test.ts +++ b/apps/cli/src/api/session/snapshotSync.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; +import { makeSessionFixtureRow } from '@/sessionControl/testFixtures'; + vi.mock('@/configuration', () => ({ configuration: { serverUrl: 'http://example.invalid' }, })); @@ -8,6 +10,41 @@ import axios from 'axios'; import { fetchSessionSnapshotUpdateFromServer } from './snapshotSync'; describe('snapshotSync.fetchSessionSnapshotUpdateFromServer', () => { + it('parses plaintext metadata/agentState when session encryptionMode is plain', async () => { + const getSpy = vi.spyOn(axios, 'get'); + getSpy.mockResolvedValueOnce({ + status: 200, + data: { + session: makeSessionFixtureRow({ + id: 's1', + encryptionMode: 'plain' as any, + metadataVersion: 2, + metadata: JSON.stringify({ path: '/tmp', host: 'localhost' }), + agentStateVersion: 1, + agentState: JSON.stringify({ controlledByUser: false }), + }), + }, + } as any); + + const res = await fetchSessionSnapshotUpdateFromServer({ + token: 't', + sessionId: 's1', + encryptionKey: new Uint8Array(32), + encryptionVariant: 'legacy', + currentMetadataVersion: 1, + currentAgentStateVersion: 0, + }); + + expect(res.metadata).toEqual({ + metadata: { path: '/tmp', host: 'localhost' }, + metadataVersion: 2, + }); + expect(res.agentState).toEqual({ + agentState: { controlledByUser: false }, + agentStateVersion: 1, + }); + }); + it('falls back to scanning /v2/sessions when the single-session route is missing (404 Not found)', async () => { const getSpy = vi.spyOn(axios, 'get'); getSpy @@ -17,7 +54,11 @@ describe('snapshotSync.fetchSessionSnapshotUpdateFromServer', () => { } as any) .mockResolvedValueOnce({ status: 200, - data: { sessions: [{ id: 's1', metadataVersion: 0, agentStateVersion: 0 }], hasNext: false, nextCursor: null }, + data: { + sessions: [makeSessionFixtureRow({ id: 's1', metadataVersion: 0, agentStateVersion: 0 })], + hasNext: false, + nextCursor: null, + }, } as any); const res = await fetchSessionSnapshotUpdateFromServer({ @@ -56,4 +97,3 @@ describe('snapshotSync.fetchSessionSnapshotUpdateFromServer', () => { expect(String(getSpy.mock.calls[0]?.[0])).toContain('/v2/sessions/s1'); }); }); - diff --git a/apps/cli/src/api/session/snapshotSync.ts b/apps/cli/src/api/session/snapshotSync.ts index c9bd036f3..5ce6b961f 100644 --- a/apps/cli/src/api/session/snapshotSync.ts +++ b/apps/cli/src/api/session/snapshotSync.ts @@ -20,6 +20,9 @@ export async function fetchSessionSnapshotUpdateFromServer(opts: { const raw = await fetchSessionByIdCompat({ token: opts.token, sessionId: opts.sessionId }); if (!raw) return {}; + const sessionEncryptionMode: 'e2ee' | 'plain' = + (raw as any)?.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + const out: { metadata?: { metadata: Metadata; metadataVersion: number }; agentState?: { agentState: AgentState | null; agentStateVersion: number }; @@ -29,12 +32,12 @@ export async function fetchSessionSnapshotUpdateFromServer(opts: { const nextMetadataVersion = typeof raw.metadataVersion === 'number' ? raw.metadataVersion : null; const rawMetadata = typeof raw.metadata === 'string' ? raw.metadata : null; if (rawMetadata && nextMetadataVersion !== null && nextMetadataVersion > opts.currentMetadataVersion) { - const decrypted = decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(rawMetadata)); - if (decrypted) { - out.metadata = { - metadata: decrypted, - metadataVersion: nextMetadataVersion, - }; + const nextMetadata = + sessionEncryptionMode === 'plain' + ? (JSON.parse(rawMetadata) as Metadata) + : decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(rawMetadata)); + if (nextMetadata) { + out.metadata = { metadata: nextMetadata, metadataVersion: nextMetadataVersion }; } } @@ -43,7 +46,12 @@ export async function fetchSessionSnapshotUpdateFromServer(opts: { const rawAgentState = typeof raw.agentState === 'string' ? raw.agentState : null; if (nextAgentStateVersion !== null && nextAgentStateVersion > opts.currentAgentStateVersion) { out.agentState = { - agentState: rawAgentState ? decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(rawAgentState)) : null, + agentState: + !rawAgentState + ? null + : sessionEncryptionMode === 'plain' + ? (JSON.parse(rawAgentState) as AgentState) + : decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(rawAgentState)), agentStateVersion: nextAgentStateVersion, }; } diff --git a/apps/cli/src/api/session/stateUpdates.plain.test.ts b/apps/cli/src/api/session/stateUpdates.plain.test.ts new file mode 100644 index 000000000..674ebd349 --- /dev/null +++ b/apps/cli/src/api/session/stateUpdates.plain.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { updateSessionMetadataWithAck } from './stateUpdates'; + +describe('stateUpdates (plaintext sessions)', () => { + it('sends + applies plaintext metadata updates when session encryption mode is plain', async () => { + const emitWithAck = vi.fn(async (_event: string, payload: any) => { + expect(typeof payload.metadata).toBe('string'); + expect(payload.metadata).toContain('"path":"'); + return { + result: 'success', + metadata: payload.metadata, + version: payload.expectedVersion + 1, + }; + }); + + const socket = { emitWithAck }; + + let metadata: any = { path: '/tmp', host: 'localhost' }; + let version = 1; + + await updateSessionMetadataWithAck({ + socket, + sessionId: 's1', + sessionEncryptionMode: 'plain', + encryptionKey: new Uint8Array(32), + encryptionVariant: 'legacy', + getMetadata: () => metadata, + setMetadata: (next) => { + metadata = next; + }, + getMetadataVersion: () => version, + setMetadataVersion: (next) => { + version = next; + }, + syncSessionSnapshotFromServer: async () => {}, + handler: (current) => ({ ...current, path: '/tmp2' }), + }); + + expect(metadata.path).toBe('/tmp2'); + expect(version).toBe(2); + }); +}); + diff --git a/apps/cli/src/api/session/stateUpdates.ts b/apps/cli/src/api/session/stateUpdates.ts index 7f2870927..8aba32add 100644 --- a/apps/cli/src/api/session/stateUpdates.ts +++ b/apps/cli/src/api/session/stateUpdates.ts @@ -10,6 +10,7 @@ type AckableSocket = { export async function updateSessionMetadataWithAck(opts: { socket: AckableSocket; sessionId: string; + sessionEncryptionMode: 'e2ee' | 'plain'; encryptionKey: Uint8Array; encryptionVariant: 'legacy' | 'dataKey'; getMetadata: () => Metadata | null; @@ -30,14 +31,22 @@ export async function updateSessionMetadataWithAck(opts: { const current = opts.getMetadata(); const updated = opts.handler(current!); + const metadataPayload = + opts.sessionEncryptionMode === 'plain' + ? JSON.stringify(updated) + : encodeBase64(encrypt(opts.encryptionKey, opts.encryptionVariant, updated)); const answer = await opts.socket.emitWithAck('update-metadata', { sid: opts.sessionId, expectedVersion: opts.getMetadataVersion(), - metadata: encodeBase64(encrypt(opts.encryptionKey, opts.encryptionVariant, updated)), + metadata: metadataPayload, }); if (answer.result === 'success') { - opts.setMetadata(decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.metadata))); + const next = + opts.sessionEncryptionMode === 'plain' + ? JSON.parse(String(answer.metadata ?? 'null')) + : decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.metadata)); + opts.setMetadata(next); opts.setMetadataVersion(answer.version); return; } @@ -45,7 +54,11 @@ export async function updateSessionMetadataWithAck(opts: { if (answer.result === 'version-mismatch') { if (answer.version > opts.getMetadataVersion()) { opts.setMetadataVersion(answer.version); - opts.setMetadata(decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.metadata))); + const next = + opts.sessionEncryptionMode === 'plain' + ? JSON.parse(String(answer.metadata ?? 'null')) + : decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.metadata)); + opts.setMetadata(next); } throw new Error('Metadata version mismatch'); } @@ -57,6 +70,7 @@ export async function updateSessionMetadataWithAck(opts: { export async function updateSessionAgentStateWithAck(opts: { socket: AckableSocket; sessionId: string; + sessionEncryptionMode: 'e2ee' | 'plain'; encryptionKey: Uint8Array; encryptionVariant: 'legacy' | 'dataKey'; getAgentState: () => AgentState | null; @@ -76,14 +90,24 @@ export async function updateSessionAgentStateWithAck(opts: { } const updated = opts.handler(opts.getAgentState() || {}); + const agentStatePayload = + opts.sessionEncryptionMode === 'plain' + ? JSON.stringify(updated) + : (updated ? encodeBase64(encrypt(opts.encryptionKey, opts.encryptionVariant, updated)) : null); const answer = await opts.socket.emitWithAck('update-state', { sid: opts.sessionId, expectedVersion: opts.getAgentStateVersion(), - agentState: updated ? encodeBase64(encrypt(opts.encryptionKey, opts.encryptionVariant, updated)) : null, + agentState: agentStatePayload, }); if (answer.result === 'success') { - opts.setAgentState(answer.agentState ? decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.agentState)) : null); + const next = + !answer.agentState + ? null + : opts.sessionEncryptionMode === 'plain' + ? JSON.parse(String(answer.agentState)) + : decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.agentState)); + opts.setAgentState(next); opts.setAgentStateVersion(answer.version); logger.debug('Agent state updated', opts.getAgentState()); return; @@ -92,7 +116,13 @@ export async function updateSessionAgentStateWithAck(opts: { if (answer.result === 'version-mismatch') { if (answer.version > opts.getAgentStateVersion()) { opts.setAgentStateVersion(answer.version); - opts.setAgentState(answer.agentState ? decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.agentState)) : null); + const next = + !answer.agentState + ? null + : opts.sessionEncryptionMode === 'plain' + ? JSON.parse(String(answer.agentState)) + : decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.agentState)); + opts.setAgentState(next); } throw new Error('Agent state version mismatch'); } @@ -100,4 +130,3 @@ export async function updateSessionAgentStateWithAck(opts: { // Hard error - ignore }); } - diff --git a/apps/cli/src/api/session/transcriptMessageLookup.test.ts b/apps/cli/src/api/session/transcriptMessageLookup.test.ts new file mode 100644 index 000000000..12b3d2aa7 --- /dev/null +++ b/apps/cli/src/api/session/transcriptMessageLookup.test.ts @@ -0,0 +1,146 @@ +import axios from 'axios'; +import fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { reloadConfiguration } from '@/configuration'; +import { installAxiosFastifyAdapter } from '@/ui/testkit/axiosFastifyAdapter.testkit'; + +describe('waitForTranscriptEncryptedMessageByLocalId', () => { + let app: FastifyInstance | null = null; + let restoreAdapter: (() => void) | null = null; + + afterEach(async () => { + vi.useRealTimers(); + restoreAdapter?.(); + restoreAdapter = null; + vi.resetModules(); + if (app) { + await app.close().catch(() => {}); + app = null; + } + }); + + it('backs off between consecutive request errors to avoid tight polling loops', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + + process.env.HAPPIER_SERVER_URL = 'http://adapter.test'; + reloadConfiguration(); + + const { waitForTranscriptEncryptedMessageByLocalId } = await import('./transcriptMessageLookup'); + + let requestCount = 0; + app = fastify({ logger: false }); + app.get('/v2/sessions/:sid/messages/by-local-id/:localId', async (_req, reply) => { + requestCount += 1; + return reply.code(503).send({ error: 'nope' }); + }); + app.get('/v1/sessions/:sid/messages', async (_req, reply) => { + return reply.code(500).send({ error: 'should not use v1 transcript scan when v2 is available' }); + }); + await app.ready(); + + restoreAdapter = installAxiosFastifyAdapter({ app, origin: 'http://adapter.test' }); + + const p = waitForTranscriptEncryptedMessageByLocalId({ + token: 'token', + sessionId: 'sid', + localId: 'lid', + maxWaitMs: 1000, + pollIntervalMs: 10, + errorBackoffBaseMs: 100, + errorBackoffMaxMs: 400, + onError: () => {}, + }); + + await vi.advanceTimersByTimeAsync(2000); + const result = await p; + + expect(result).toBeNull(); + expect(requestCount).toBe(4); + + // sanity: the adapter should have been exercised via axios + expect(typeof axios.get).toBe('function'); + }); + + it('caps per-request timeout to the remaining maxWaitMs', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + + process.env.HAPPIER_SERVER_URL = 'http://adapter.test'; + reloadConfiguration(); + + const { waitForTranscriptEncryptedMessageByLocalId } = await import('./transcriptMessageLookup'); + + const observedTimeouts: number[] = []; + const getSpy = vi.spyOn(axios, 'get').mockImplementation((async (_url: string, config?: any) => { + observedTimeouts.push(config?.timeout); + await new Promise((_resolve, reject) => setTimeout(() => reject(new Error('boom')), config?.timeout ?? 0)); + throw new Error('unreachable'); + }) as any); + + const p = waitForTranscriptEncryptedMessageByLocalId({ + token: 'token', + sessionId: 'sid', + localId: 'lid', + maxWaitMs: 500, + requestTimeoutMs: 10_000, + pollIntervalMs: 1, + errorBackoffBaseMs: 1, + errorBackoffMaxMs: 1, + onError: () => {}, + }); + + try { + await vi.advanceTimersByTimeAsync(20_000); + const result = await p; + + expect(result).toBeNull(); + expect(observedTimeouts[0]).toBe(500); + } finally { + getSpy.mockRestore(); + } + }); + + it('does not fall back to v1 transcript scanning when the v2 localId route is missing', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + + process.env.HAPPIER_SERVER_URL = 'http://adapter.test'; + reloadConfiguration(); + + const { waitForTranscriptEncryptedMessageByLocalId } = await import('./transcriptMessageLookup'); + + const calls: string[] = []; + app = fastify({ logger: false }); + app.get('/v2/sessions/:sid/messages/by-local-id/:localId', async (req: any, reply) => { + calls.push(`v2:${req.params.sid}:${req.params.localId}`); + // Simulate an older server that does not implement this route. + return reply.code(404).send({ error: 'Not found', path: `/v2/sessions/${req.params.sid}/messages/by-local-id/${req.params.localId}` }); + }); + app.get('/v1/sessions/:sid/messages', async (req: any, reply) => { + calls.push(`v1:${req.params.sid}`); + return reply.code(500).send({ error: 'v1 should not be used for localId lookup' }); + }); + await app.ready(); + restoreAdapter = installAxiosFastifyAdapter({ app, origin: 'http://adapter.test' }); + + const p = waitForTranscriptEncryptedMessageByLocalId({ + token: 'token', + sessionId: 'sid', + localId: 'lid', + maxWaitMs: 100, + pollIntervalMs: 10, + errorBackoffBaseMs: 10, + errorBackoffMaxMs: 10, + onError: () => {}, + }); + + await vi.advanceTimersByTimeAsync(200); + const result = await p; + + expect(result).toBeNull(); + expect(calls.some((v) => v.startsWith('v1:'))).toBe(false); + expect(calls.filter((v) => v.startsWith('v2:')).length).toBeGreaterThan(0); + }); +}); diff --git a/apps/cli/src/api/session/transcriptMessageLookup.ts b/apps/cli/src/api/session/transcriptMessageLookup.ts index 1525bf452..36870388a 100644 --- a/apps/cli/src/api/session/transcriptMessageLookup.ts +++ b/apps/cli/src/api/session/transcriptMessageLookup.ts @@ -1,64 +1,143 @@ import axios from 'axios'; +import { Agent as HttpAgent } from 'node:http'; +import { Agent as HttpsAgent } from 'node:https'; import { configuration } from '@/configuration'; import { resolveLoopbackHttpUrl } from '../client/loopbackUrl'; +import { SessionMessageContentSchema, type SessionMessageContent } from '../types'; + +const KEEP_ALIVE_HTTP_AGENT = new HttpAgent({ keepAlive: true, maxSockets: 16 }); +const KEEP_ALIVE_HTTPS_AGENT = new HttpsAgent({ keepAlive: true, maxSockets: 16 }); export type TranscriptMessageLookupResult = { id: string; seq: number; localId: string | null; - content: { t: 'encrypted'; c: string }; + content: SessionMessageContent; }; -export async function findTranscriptEncryptedMessageByLocalId(params: { +function createAxiosGetConfig(params: { token: string; timeoutMs?: number }) { + return { + headers: { + Authorization: `Bearer ${params.token}`, + 'Content-Type': 'application/json', + }, + timeout: params.timeoutMs ?? configuration.transcriptLookupRequestTimeoutMs, + ...(configuration.transcriptLookupKeepAliveEnabled + ? { httpAgent: KEEP_ALIVE_HTTP_AGENT, httpsAgent: KEEP_ALIVE_HTTPS_AGENT } + : null), + } as const; +} + +function isV2MessageNotFoundError(error: unknown): boolean { + if (!axios.isAxiosError(error)) return false; + if (error.response?.status !== 404) return false; + const data = error.response?.data as unknown; + if (!data || typeof data !== 'object') return false; + const record = data as Record; + return record.error === 'Message not found'; +} + +function parseTranscriptLookupMessageFromUnknown(found: any): TranscriptMessageLookupResult | null { + if (!found || typeof found !== 'object') return null; + const content = SessionMessageContentSchema.safeParse(found.content); + if (!content.success) return null; + if (typeof found.id !== 'string') return null; + if (typeof found.seq !== 'number') return null; + const foundLocalId = typeof found.localId === 'string' ? found.localId : null; + return { id: found.id, seq: found.seq, localId: foundLocalId, content: content.data }; +} + +async function findTranscriptEncryptedMessageByLocalIdV2(params: { token: string; + serverUrl: string; sessionId: string; localId: string; onError?: (error: unknown) => void; + timeoutMs?: number; }): Promise { try { - const serverUrl = resolveLoopbackHttpUrl(configuration.serverUrl).replace(/\/+$/, ''); - const response = await axios.get(`${serverUrl}/v1/sessions/${params.sessionId}/messages`, { - headers: { - Authorization: `Bearer ${params.token}`, - 'Content-Type': 'application/json', - }, - timeout: 10_000, - }); - const messages = (response?.data as any)?.messages; - if (!Array.isArray(messages)) return null; - const found = messages.find((m: any) => m && typeof m === 'object' && m.localId === params.localId); - if (!found) return null; - const content = found.content; - if (!content || content.t !== 'encrypted' || typeof content.c !== 'string') return null; - if (typeof found.id !== 'string') return null; - if (typeof found.seq !== 'number') return null; - const foundLocalId = typeof found.localId === 'string' ? found.localId : null; - return { id: found.id, seq: found.seq, localId: foundLocalId, content: { t: 'encrypted', c: content.c } }; + const response = await axios.get( + `${params.serverUrl}/v2/sessions/${params.sessionId}/messages/by-local-id/${encodeURIComponent(params.localId)}`, + createAxiosGetConfig({ token: params.token, timeoutMs: params.timeoutMs }) + ); + const data = response?.data as unknown; + const message = data && typeof data === 'object' ? (data as Record).message : null; + const parsed = parseTranscriptLookupMessageFromUnknown(message as any); + if (!parsed) return null; + return parsed; } catch (error) { + if (isV2MessageNotFoundError(error)) return null; params.onError?.(error); return null; } } +export async function findTranscriptEncryptedMessageByLocalId(params: { + token: string; + sessionId: string; + localId: string; + onError?: (error: unknown) => void; + timeoutMs?: number; +}): Promise { + const serverUrl = resolveLoopbackHttpUrl(configuration.serverUrl).replace(/\/+$/, ''); + return await findTranscriptEncryptedMessageByLocalIdV2({ + token: params.token, + serverUrl, + sessionId: params.sessionId, + localId: params.localId, + timeoutMs: params.timeoutMs, + onError: params.onError, + }); +} + export async function waitForTranscriptEncryptedMessageByLocalId(params: { token: string; sessionId: string; localId: string; maxWaitMs?: number; onError?: (error: unknown) => void; + pollIntervalMs?: number; + errorBackoffBaseMs?: number; + errorBackoffMaxMs?: number; + requestTimeoutMs?: number; }): Promise { const maxWaitMs = params.maxWaitMs ?? 5_000; + const pollIntervalMs = params.pollIntervalMs ?? configuration.transcriptLookupPollIntervalMs; + const errorBackoffBaseMs = params.errorBackoffBaseMs ?? configuration.transcriptLookupErrorBackoffBaseMs; + const errorBackoffMaxMs = params.errorBackoffMaxMs ?? configuration.transcriptLookupErrorBackoffMaxMs; + const requestTimeoutMs = params.requestTimeoutMs ?? configuration.transcriptLookupRequestTimeoutMs; const startedAt = Date.now(); + let currentErrorBackoffMs = errorBackoffBaseMs; while (Date.now() - startedAt < maxWaitMs) { + const elapsedMs = Date.now() - startedAt; + const remainingMs = maxWaitMs - elapsedMs; + if (remainingMs <= 0) break; + + let hadError = false; const found = await findTranscriptEncryptedMessageByLocalId({ token: params.token, sessionId: params.sessionId, localId: params.localId, - onError: params.onError, + timeoutMs: Math.max(1, Math.min(requestTimeoutMs, remainingMs)), + onError: (error) => { + hadError = true; + params.onError?.(error); + }, }); if (found) return found; - await new Promise((r) => setTimeout(r, 150)); + + const delayMs = hadError ? currentErrorBackoffMs : pollIntervalMs; + if (hadError) { + currentErrorBackoffMs = Math.min(errorBackoffMaxMs, currentErrorBackoffMs * 2); + } else { + currentErrorBackoffMs = errorBackoffBaseMs; + } + + const remainingAfterAttemptMs = maxWaitMs - (Date.now() - startedAt); + if (remainingAfterAttemptMs <= 0) break; + + await new Promise((r) => setTimeout(r, Math.min(delayMs, remainingAfterAttemptMs))); } return null; } diff --git a/apps/cli/src/api/session/transcriptQueries.plain.test.ts b/apps/cli/src/api/session/transcriptQueries.plain.test.ts new file mode 100644 index 000000000..3e81be96a --- /dev/null +++ b/apps/cli/src/api/session/transcriptQueries.plain.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/configuration', () => ({ + configuration: { serverUrl: 'http://example.test' }, +})) + +vi.mock('@/ui/logger', () => ({ + logger: { debug: vi.fn() }, +})) + +vi.mock('../client/loopbackUrl', () => ({ + resolveLoopbackHttpUrl: (url: string) => url, +})) + +import axios from 'axios' + +import { fetchLatestUserPermissionIntentFromEncryptedTranscript } from './transcriptQueries' + +describe('transcriptQueries (plaintext envelopes)', () => { + it('resolves permission intent from plaintext transcript messages', async () => { + vi.spyOn(axios, 'get').mockResolvedValueOnce({ + data: { + messages: [ + { + createdAt: 123, + content: { + t: 'plain', + v: { + role: 'user', + content: { type: 'text', text: 'hello' }, + meta: { permissionMode: 'yolo' }, + }, + }, + }, + ], + }, + } as any) + + const res = await fetchLatestUserPermissionIntentFromEncryptedTranscript({ + token: 't', + sessionId: 's1', + encryptionKey: new Uint8Array(32), + encryptionVariant: 'dataKey', + }) + + expect(res).toEqual({ intent: 'yolo', updatedAt: 123 }) + }) +}) diff --git a/apps/cli/src/api/session/transcriptQueries.ts b/apps/cli/src/api/session/transcriptQueries.ts index 9267fd308..13495c24f 100644 --- a/apps/cli/src/api/session/transcriptQueries.ts +++ b/apps/cli/src/api/session/transcriptQueries.ts @@ -7,7 +7,7 @@ import { logger } from '@/ui/logger'; import { resolveLoopbackHttpUrl } from '../client/loopbackUrl'; import { decodeBase64, decrypt } from '../encryption'; -import type { PermissionMode } from '../types'; +import { SessionMessageContentSchema, type PermissionMode } from '../types'; type EncryptionVariant = 'legacy' | 'dataKey'; @@ -38,7 +38,8 @@ export async function fetchRecentTranscriptTextItemsForAcpImportFromServer( timeout: 10_000, }); - const raw = (response?.data as any)?.messages; + const data = response?.data as unknown; + const raw = data && typeof data === 'object' ? (data as Record).messages : null; if (!Array.isArray(raw)) return []; const sliced = raw.slice(0, take); @@ -46,30 +47,46 @@ export async function fetchRecentTranscriptTextItemsForAcpImportFromServer( for (const msg of sliced) { const content = msg?.content; - if (!content || content.t !== 'encrypted' || typeof content.c !== 'string') continue; - - const decrypted = decrypt( - params.encryptionKey, - params.encryptionVariant, - decodeBase64(content.c), - ) as any; - const role = decrypted?.role; + const parsedContent = SessionMessageContentSchema.safeParse(content); + if (!parsedContent.success) continue; + + let decrypted: unknown; + if (parsedContent.data.t === 'plain') { + decrypted = parsedContent.data.v; + } else { + decrypted = decrypt( + params.encryptionKey, + params.encryptionVariant, + decodeBase64(parsedContent.data.c), + ); + } + const decryptedObj = decrypted && typeof decrypted === 'object' ? (decrypted as Record) : null; + const role = decryptedObj?.role; if (role !== 'user' && role !== 'agent') continue; let text: string | null = null; - const body = decrypted?.content; + const body = decryptedObj?.content; + const bodyObj = body && typeof body === 'object' ? (body as Record) : null; + const bodyType = bodyObj?.type; if (role === 'user') { - if (body?.type === 'text' && typeof body.text === 'string') { - text = body.text; + if (bodyType === 'text') { + const rawText = bodyObj?.text; + if (typeof rawText === 'string') { + text = rawText; + } + } + } else if (bodyType === 'text') { + const rawText = bodyObj?.text; + if (typeof rawText === 'string') { + text = rawText; } - } else if (body?.type === 'text' && typeof body.text === 'string') { - text = body.text; - } else if (body?.type === 'acp') { - const data = body?.data; - if (data?.type === 'message' && typeof data.message === 'string') { - text = data.message; - } else if (data?.type === 'reasoning' && typeof data.message === 'string') { - text = data.message; + } else if (bodyType === 'acp') { + const data = bodyObj?.data; + const dataObj = data && typeof data === 'object' ? (data as Record) : null; + const dataType = dataObj?.type; + const dataMessage = dataObj?.message; + if ((dataType === 'message' || dataType === 'reasoning') && typeof dataMessage === 'string') { + text = dataMessage; } } @@ -106,7 +123,8 @@ export async function fetchLatestUserPermissionIntentFromEncryptedTranscript( timeout: 10_000, }); - const raw = (response?.data as any)?.messages; + const data = response?.data as unknown; + const raw = data && typeof data === 'object' ? (data as Record).messages : null; if (!Array.isArray(raw)) return null; const sliced = raw.slice(0, take); @@ -116,17 +134,24 @@ export async function fetchLatestUserPermissionIntentFromEncryptedTranscript( const createdAt = typeof msg?.createdAt === 'number' ? msg.createdAt : null; if (createdAt === null) continue; const content = msg?.content; - if (!content || content.t !== 'encrypted' || typeof content.c !== 'string') continue; - - const decrypted = decrypt( - params.encryptionKey, - params.encryptionVariant, - decodeBase64(content.c), - ) as any; - if (decrypted?.role !== 'user') continue; + const parsedContent = SessionMessageContentSchema.safeParse(content); + if (!parsedContent.success) continue; + + let decrypted: unknown; + if (parsedContent.data.t === 'plain') { + decrypted = parsedContent.data.v; + } else { + decrypted = decrypt( + params.encryptionKey, + params.encryptionVariant, + decodeBase64(parsedContent.data.c), + ); + } + const decryptedObj = decrypted && typeof decrypted === 'object' ? (decrypted as Record) : null; + if (decryptedObj?.role !== 'user') continue; - const meta = decrypted?.meta; - const rawMode = meta && typeof meta === 'object' ? (meta as any).permissionMode : null; + const meta = decryptedObj?.meta; + const rawMode = meta && typeof meta === 'object' ? (meta as Record).permissionMode : null; if (typeof rawMode !== 'string' || rawMode.trim().length === 0) continue; candidates.push({ rawMode, updatedAt: createdAt }); diff --git a/apps/cli/src/api/session/transcriptRecoveryScheduler.test.ts b/apps/cli/src/api/session/transcriptRecoveryScheduler.test.ts new file mode 100644 index 000000000..176112d6c --- /dev/null +++ b/apps/cli/src/api/session/transcriptRecoveryScheduler.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createKeyedSingleFlightScheduler } from './transcriptRecoveryScheduler'; + +describe('createKeyedSingleFlightScheduler', () => { + function createDeferredVoid(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + } + + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs at most once for the same key when scheduled multiple times before the delay', async () => { + vi.useFakeTimers(); + + const scheduler = createKeyedSingleFlightScheduler({ delayMs: 10 }); + const run = vi.fn(async () => {}); + + scheduler.schedule('a', run); + scheduler.schedule('a', run); + scheduler.schedule('a', run); + + await vi.runAllTimersAsync(); + + expect(run).toHaveBeenCalledTimes(1); + }); + + it('does not start a second run for the same key while the first run is in-flight', async () => { + vi.useFakeTimers(); + + const scheduler = createKeyedSingleFlightScheduler({ delayMs: 10 }); + + const deferred = createDeferredVoid(); + const run = vi.fn(async () => { + await deferred.promise; + }); + + scheduler.schedule('a', run); + await vi.runAllTimersAsync(); + expect(run).toHaveBeenCalledTimes(1); + + scheduler.schedule('a', run); + await vi.runOnlyPendingTimersAsync(); + expect(run).toHaveBeenCalledTimes(1); + + deferred.resolve(); + await vi.runAllTimersAsync(); + + scheduler.schedule('a', run); + await vi.runAllTimersAsync(); + expect(run).toHaveBeenCalledTimes(2); + }); + + it('cancel prevents a scheduled run from starting', async () => { + vi.useFakeTimers(); + + const scheduler = createKeyedSingleFlightScheduler({ delayMs: 10 }); + const run = vi.fn(async () => {}); + + scheduler.schedule('a', run); + scheduler.cancel('a'); + + await vi.runAllTimersAsync(); + expect(run).toHaveBeenCalledTimes(0); + }); + + it('limits concurrent runs across keys', async () => { + vi.useFakeTimers(); + + const scheduler = createKeyedSingleFlightScheduler({ delayMs: 0, maxConcurrent: 1 }); + + const deferredA = createDeferredVoid(); + const runA = vi.fn(async () => { + await deferredA.promise; + }); + + const runB = vi.fn(async () => {}); + + scheduler.schedule('a', runA); + scheduler.schedule('b', runB); + + await vi.runAllTimersAsync(); + expect(runA).toHaveBeenCalledTimes(1); + expect(runB).toHaveBeenCalledTimes(0); + + deferredA.resolve(); + await vi.runAllTimersAsync(); + + expect(runB).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/cli/src/api/session/transcriptRecoveryScheduler.ts b/apps/cli/src/api/session/transcriptRecoveryScheduler.ts new file mode 100644 index 000000000..c4fe774de --- /dev/null +++ b/apps/cli/src/api/session/transcriptRecoveryScheduler.ts @@ -0,0 +1,124 @@ +export type KeyedSingleFlightScheduler = { + schedule: (key: string, run: () => Promise) => void; + cancel: (key: string) => void; +}; + +type Entry = { + timer: ReturnType | null; + inFlight: Promise | null; + queued: boolean; + pendingRun: (() => Promise) | null; +}; + +export function createKeyedSingleFlightScheduler(params: Readonly<{ delayMs: number; maxConcurrent?: number }>): KeyedSingleFlightScheduler { + const entries = new Map(); + const readyQueue: string[] = []; + const maxConcurrent = (() => { + const value = params.maxConcurrent; + if (typeof value !== 'number') return Number.POSITIVE_INFINITY; + if (!Number.isFinite(value)) return Number.POSITIVE_INFINITY; + return Math.max(1, Math.floor(value)); + })(); + let activeCount = 0; + + function getOrCreateEntry(key: string): Entry { + const existing = entries.get(key); + if (existing) return existing; + const created: Entry = { timer: null, inFlight: null, queued: false, pendingRun: null }; + entries.set(key, created); + return created; + } + + function maybeDeleteEntry(key: string, entry: Entry): void { + if (entry.timer) return; + if (entry.inFlight) return; + if (entry.queued) return; + if (entry.pendingRun) return; + entries.delete(key); + } + + function dequeueKey(key: string): void { + const idx = readyQueue.indexOf(key); + if (idx < 0) return; + readyQueue.splice(idx, 1); + } + + function drainQueue(): void { + while (activeCount < maxConcurrent && readyQueue.length > 0) { + const key = readyQueue.shift(); + if (!key) continue; + const entry = entries.get(key); + if (!entry) continue; + entry.queued = false; + if (entry.timer || entry.inFlight || !entry.pendingRun) { + maybeDeleteEntry(key, entry); + continue; + } + startRunIfCapacity(key, entry); + } + } + + function startRunIfCapacity(key: string, entry: Entry): void { + if (entry.inFlight) return; + const run = entry.pendingRun; + if (!run) { + maybeDeleteEntry(key, entry); + return; + } + + if (activeCount >= maxConcurrent) { + if (!entry.queued) { + entry.queued = true; + readyQueue.push(key); + } + return; + } + + entry.pendingRun = null; + activeCount += 1; + const p = (async () => { + try { + await run(); + } catch { + // best-effort only; callers handle their own error reporting + } + })(); + + entry.inFlight = p.finally(() => { + activeCount -= 1; + entry.inFlight = null; + maybeDeleteEntry(key, entry); + drainQueue(); + }); + } + + return { + schedule(key, run) { + if (!key) return; + const entry = getOrCreateEntry(key); + if (entry.timer || entry.inFlight || entry.queued || entry.pendingRun) return; + + entry.pendingRun = run; + entry.timer = setTimeout(() => { + entry.timer = null; + startRunIfCapacity(key, entry); + }, params.delayMs); + entry.timer.unref?.(); + }, + + cancel(key) { + const entry = entries.get(key); + if (!entry) return; + if (entry.timer) { + clearTimeout(entry.timer); + entry.timer = null; + } + if (entry.queued) { + entry.queued = false; + dequeueKey(key); + } + entry.pendingRun = null; + maybeDeleteEntry(key, entry); + }, + }; +} diff --git a/apps/cli/src/api/sessionClient.test.ts b/apps/cli/src/api/sessionClient.test.ts index 7b0cebfcb..662ea0cd5 100644 --- a/apps/cli/src/api/sessionClient.test.ts +++ b/apps/cli/src/api/sessionClient.test.ts @@ -53,6 +53,7 @@ describe('ApiSessionClient connection handling', () => { mockSession = { id: 'test-session-id', seq: 0, + encryptionMode: 'e2ee' as const, metadata: { path: '/tmp', host: 'localhost', @@ -96,6 +97,21 @@ describe('ApiSessionClient connection handling', () => { expect(getSpy).toHaveBeenCalledTimes(2); }); + it('sends plaintext session messages when session.encryptionMode is plain', async () => { + const client = new ApiSessionClient('fake-token', { ...mockSession, encryptionMode: 'plain' as const }); + + client.sendUserTextMessage('hello'); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'message', + expect.objectContaining({ + sid: 'test-session-id', + message: expect.objectContaining({ t: 'plain', v: expect.anything() }), + localId: expect.any(String), + }), + ); + }); + it('normalizes outbound ACP tool-call names and inputs to V2 canonical keys', () => { const client = new ApiSessionClient('fake-token', mockSession); client.sendAgentMessage('opencode', { @@ -290,6 +306,44 @@ describe('ApiSessionClient connection handling', () => { }); }); + it('delivers plaintext new-message updates without decrypting', () => { + const client = new ApiSessionClient('fake-token', mockSession); + + const received: any[] = []; + client.onUserMessage((msg: any) => received.push(msg)); + + const plaintext = { + role: 'user', + content: { type: 'text', text: 'hello' }, + meta: { source: 'ui', sentFrom: 'e2e', permissionMode: 'read-only' }, + }; + + const update = { + id: 'u-1', + seq: 0, + createdAt: 1234, + body: { + t: 'new-message', + sid: mockSession.id, + message: { + id: 'm-1', + seq: 1, + content: { t: 'plain', v: plaintext }, + }, + }, + }; + + (client as any).handleUpdate(update, { source: 'session-scoped' }); + + expect(received.length).toBe(1); + expect(received[0]).toMatchObject({ + role: 'user', + content: { type: 'text', text: 'hello' }, + meta: { permissionMode: 'read-only' }, + createdAt: 1234, + }); + }); + it('consumes daemon initial prompt env and seeds one user prompt on callback attach', () => { process.env.HAPPIER_DAEMON_INITIAL_PROMPT = ' run nightly health check '; @@ -1162,10 +1216,20 @@ describe('ApiSessionClient connection handling', () => { data: { session: { id: mockSession.id, + seq: 0, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + archivedAt: null, metadataVersion: 5, metadata: encryptedServerMetadata, agentStateVersion: 0, agentState: null, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + share: null, }, }, }); diff --git a/apps/cli/src/api/testkit/sessionClientTestkit.ts b/apps/cli/src/api/testkit/sessionClientTestkit.ts index 742d8edad..8e24b5b23 100644 --- a/apps/cli/src/api/testkit/sessionClientTestkit.ts +++ b/apps/cli/src/api/testkit/sessionClientTestkit.ts @@ -10,6 +10,7 @@ export function createMockSession(overrides: RecordLike = {}) { const base = { id: 'test-session-id', seq: 0, + encryptionMode: 'e2ee' as const, metadata: { path: '/tmp', host: 'localhost', diff --git a/apps/cli/src/api/types.messageMeta.passthrough.test.ts b/apps/cli/src/api/types.messageMeta.passthrough.test.ts index 7a17342aa..e54945222 100644 --- a/apps/cli/src/api/types.messageMeta.passthrough.test.ts +++ b/apps/cli/src/api/types.messageMeta.passthrough.test.ts @@ -10,7 +10,7 @@ describe('MessageMetaSchema', () => { claudeRemoteAgentSdkEnabled: true, }); - expect(parsed.sentFrom).toBe('unknown'); + expect(parsed.sentFrom).toBe('e2e'); expect((parsed as any).claudeRemoteAgentSdkEnabled).toBe(true); }); }); diff --git a/apps/cli/src/api/types.sessionMessageContent.test.ts b/apps/cli/src/api/types.sessionMessageContent.test.ts new file mode 100644 index 000000000..b9d1de2a1 --- /dev/null +++ b/apps/cli/src/api/types.sessionMessageContent.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' + +import { SessionMessageContentSchema } from './types' + +describe('SessionMessageContentSchema', () => { + it('accepts encrypted envelopes', () => { + const parsed = SessionMessageContentSchema.safeParse({ t: 'encrypted', c: 'aGVsbG8=' }) + expect(parsed.success).toBe(true) + }) + + it('accepts plaintext envelopes', () => { + const parsed = SessionMessageContentSchema.safeParse({ t: 'plain', v: { kind: 'user-text', text: 'hello' } }) + expect(parsed.success).toBe(true) + }) +}) + diff --git a/apps/cli/src/api/types.ts b/apps/cli/src/api/types.ts index 6f0e16cd5..548277f97 100644 --- a/apps/cli/src/api/types.ts +++ b/apps/cli/src/api/types.ts @@ -2,6 +2,9 @@ import { z } from 'zod' import { UsageSchema } from '@/api/usage' import { SOCKET_RPC_EVENTS } from '@happier-dev/protocol/socketRpc' import { SentFromSchema } from '@happier-dev/protocol' +import type { AcpConfigOptionOverridesV1, AcpSessionModeOverrideV1, ModelOverrideV1, SessionTerminalMetadata } from '@happier-dev/protocol' +import { SESSION_PERMISSION_MODES, createSessionPermissionModeSchema } from '@happier-dev/protocol' +import { SessionStoredMessageContentSchema, type SessionStoredMessageContent } from '@happier-dev/protocol' export { EphemeralUpdateSchema, @@ -37,19 +40,11 @@ import type { * - safe-yolo → default * - read-only → default */ +export const PERMISSION_MODES = SESSION_PERMISSION_MODES + const CODEX_GEMINI_NON_DEFAULT_PERMISSION_MODES = ['read-only', 'safe-yolo', 'yolo'] as const export const CODEX_GEMINI_PERMISSION_MODES = ['default', ...CODEX_GEMINI_NON_DEFAULT_PERMISSION_MODES] as const -const CLAUDE_ONLY_PERMISSION_MODES = ['acceptEdits', 'bypassPermissions', 'plan'] as const - -// Keep stable ordering for readability/help text: -// default, claude-only, then codex/gemini-only. -export const PERMISSION_MODES = [ - 'default', - ...CLAUDE_ONLY_PERMISSION_MODES, - ...CODEX_GEMINI_NON_DEFAULT_PERMISSION_MODES, -] as const - export type PermissionMode = (typeof PERMISSION_MODES)[number] export function isPermissionMode(value: string): value is PermissionMode { @@ -83,14 +78,10 @@ export function isCodexPermissionMode(value: PermissionMode): value is CodexPerm export type Usage = z.infer /** - * Base message content structure for encrypted messages + * Session message content envelopes */ -export const SessionMessageContentSchema = z.object({ - c: z.string(), // Base64 encoded encrypted content - t: z.literal('encrypted') -}) - -export type SessionMessageContent = z.infer +export const SessionMessageContentSchema = SessionStoredMessageContentSchema +export type SessionMessageContent = SessionStoredMessageContent /** * Update events @@ -127,7 +118,7 @@ export interface ServerToClientEvents { */ export interface ClientToServerEvents { message: ( - data: { sid: string, message: any, localId?: string | null }, + data: { sid: string, message: string | SessionMessageContent, localId?: string | null, echoToSender?: boolean }, cb?: (answer: MessageAckResponse) => void ) => void 'session-alive': (data: { @@ -167,6 +158,7 @@ export interface ClientToServerEvents { export type Session = { id: string, seq: number, + encryptionMode: 'e2ee' | 'plain', encryptionKey: Uint8Array; encryptionVariant: 'legacy' | 'dataKey'; metadata: Metadata, @@ -246,7 +238,7 @@ export const MessageMetaSchema = z.object({ * Forward-compatible: unknown strings are allowed. */ source: z.union([z.enum(['cli', 'ui']), z.string()]).optional(), - permissionMode: z.enum(PERMISSION_MODES).optional(), // Permission mode for this message + permissionMode: createSessionPermissionModeSchema(z).optional(), // Permission mode for this message model: z.string().nullable().optional(), // Model name for this message (null = reset) fallbackModel: z.string().nullable().optional(), // Fallback model for this message (null = reset) customSystemPrompt: z.string().nullable().optional(), // Custom system prompt for this message (null = reset) @@ -322,15 +314,7 @@ export type Metadata = { * Terminal/attach metadata for this Happy session (non-secret). * Used by the UI (Session Details) and CLI attach flows. */ - terminal?: { - mode: 'plain' | 'tmux', - requested?: 'plain' | 'tmux', - fallbackReason?: string, - tmux?: { - target: string, - tmpDir?: string | null, - }, - }, + terminal?: SessionTerminalMetadata, /** * Session-scoped profile identity (non-secret). * Used for display/debugging across devices; runtime behavior is still driven by env vars at spawn. @@ -353,6 +337,7 @@ export type Metadata = { kimiSessionId?: string, // Kimi ACP session ID (opaque) kiloSessionId?: string, // Kilo ACP session ID (opaque) piSessionId?: string, // Pi RPC session ID (opaque) + copilotSessionId?: string, // Copilot ACP session ID (opaque) auggieAllowIndexing?: boolean, // Auggie indexing enablement (spawn-time) tools?: string[], slashCommands?: string[], @@ -428,26 +413,13 @@ export type Metadata = { * * Distinct from `acpSessionModesV1` (which mirrors agent-reported current state). */ - acpSessionModeOverrideV1?: { - v: 1, - updatedAt: number, - modeId: string, - }, + acpSessionModeOverrideV1?: AcpSessionModeOverrideV1, /** * Desired ACP configuration option overrides selected by the user (UI/CLI). * * This is a best-effort mechanism to keep ACP "configOptions" selections consistent across devices. */ - acpConfigOptionOverridesV1?: { - v: 1, - updatedAt: number, - overrides: { - [configId: string]: { - updatedAt: number, - value: string | number | boolean | null, - }, - }, - }, + acpConfigOptionOverridesV1?: AcpConfigOptionOverridesV1, homeDir: string, happyHomeDir: string, happyLibDir: string, @@ -475,11 +447,7 @@ export type Metadata = { * This is session-scoped and should be applied by runners in a capability-driven way * (some agents support live model switching; others may require a new session). */ - modelOverrideV1?: { - v: 1, - updatedAt: number, - modelId: string, - }, + modelOverrideV1?: ModelOverrideV1, }; export type AgentState = { diff --git a/apps/cli/src/backends/catalog.ts b/apps/cli/src/backends/catalog.ts index 6da12c1f5..149b6ec04 100644 --- a/apps/cli/src/backends/catalog.ts +++ b/apps/cli/src/backends/catalog.ts @@ -2,6 +2,7 @@ import type { AgentId } from '@/agent/core'; import { agent as auggie } from '@/backends/auggie'; import { agent as claude } from '@/backends/claude'; import { agent as codex } from '@/backends/codex'; +import { agent as copilot } from '@/backends/copilot'; import { agent as gemini } from '@/backends/gemini'; import { agent as kimi } from '@/backends/kimi'; import { agent as kilo } from '@/backends/kilo'; @@ -23,6 +24,7 @@ export const AGENTS: Record = { kimi, kilo, pi, + copilot, }; const cachedVendorResumeSupportPromises = new Map>(); diff --git a/apps/cli/src/backends/claude/claudeLocal.test.ts b/apps/cli/src/backends/claude/claudeLocal.test.ts index 5d542e42a..79d666e03 100644 --- a/apps/cli/src/backends/claude/claudeLocal.test.ts +++ b/apps/cli/src/backends/claude/claudeLocal.test.ts @@ -329,6 +329,33 @@ describe('claudeLocal --continue handling', () => { claudeArgs: [], })).resolves.toBeTruthy(); }); + + it('places positional prompts after flags (so Claude can parse flags correctly)', async () => { + mockClaudeFindLastSession.mockReturnValue(null); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + hookSettingsPath: '/tmp/settings.json', + claudeArgs: ['--verbose', 'fix the bug in main.ts', '--model', 'opus'], + }); + + expect(mockSpawn).toHaveBeenCalled(); + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + + const settingsIndex = spawnArgs.indexOf('--settings'); + const modelIndex = spawnArgs.indexOf('--model'); + const promptIndex = spawnArgs.indexOf('fix the bug in main.ts'); + expect(settingsIndex).toBeGreaterThan(-1); + expect(modelIndex).toBeGreaterThan(-1); + expect(promptIndex).toBeGreaterThan(-1); + + // Prompt must be after all flags (including --settings). + expect(promptIndex).toBeGreaterThan(settingsIndex + 1); + expect(promptIndex).toBeGreaterThan(modelIndex + 1); + }); }); describe('claudeLocal launcher selection', () => { @@ -416,4 +443,31 @@ describe('claudeLocal launcher selection', () => { expect(spawnOpts?.env?.HAPPIER_CLAUDE_PATH).toBe('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'); expect(spawnOpts?.env?.DISABLE_AUTOUPDATER).toBe('1'); }); + + it('strips nested Claude Code env vars from the spawned process environment', async () => { + const prevClaudeCode = process.env.CLAUDECODE; + const prevEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT; + process.env.CLAUDECODE = '1'; + process.env.CLAUDE_CODE_ENTRYPOINT = 'parent'; + + try { + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: [], + }); + + expect(mockSpawn).toHaveBeenCalled(); + const spawnOpts = mockSpawn.mock.calls[0][2]; + expect(spawnOpts?.env?.CLAUDECODE).toBeUndefined(); + expect(spawnOpts?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + } finally { + if (typeof prevClaudeCode === 'string') process.env.CLAUDECODE = prevClaudeCode; + else delete process.env.CLAUDECODE; + if (typeof prevEntrypoint === 'string') process.env.CLAUDE_CODE_ENTRYPOINT = prevEntrypoint; + else delete process.env.CLAUDE_CODE_ENTRYPOINT; + } + }); }); diff --git a/apps/cli/src/backends/claude/claudeLocal.ts b/apps/cli/src/backends/claude/claudeLocal.ts index 1785ce875..ce6ecebbf 100644 --- a/apps/cli/src/backends/claude/claudeLocal.ts +++ b/apps/cli/src/backends/claude/claudeLocal.ts @@ -13,6 +13,7 @@ import { systemPrompt } from "./utils/systemPrompt"; import { restoreStdinBestEffort } from "@/ui/ink/restoreStdinBestEffort"; import { isClaudeCliJavaScriptFile, resolveClaudeCliPath } from "./utils/resolveClaudeCliPath"; import { isBun } from "@/utils/runtime"; +import { stripNestedSessionDetectionEnv } from "@/utils/processEnv/stripNestedSessionDetectionEnv"; /** * Error thrown when the Claude process exits with a non-zero exit code. @@ -222,26 +223,64 @@ export async function claudeLocal(opts: { args.push('--allowedTools', opts.allowedTools.join(',')); } + // Claude CLI treats the first non-flag token as the prompt. If a positional prompt + // is provided before later flags, those flags can be mis-parsed as prompt text. + // Ensure positional args come after all flags (including our injected --settings). + const flagArgs: string[] = []; + const positionalArgs: string[] = []; + const flagsWithValue = new Set([ + '--model', + '--permission-mode', + '--settings', + '--mcp-config', + '--allowedTools', + '--disallowedTools', + '--output-format', + '--input-format', + '--print', + '--append-system-prompt', + '--resume', + '--session-id', + ]); + + if (opts.claudeArgs) { + for (let i = 0; i < opts.claudeArgs.length; i++) { + const arg = opts.claudeArgs[i]; + if (arg.startsWith('-')) { + flagArgs.push(arg); + if (flagsWithValue.has(arg) && i + 1 < opts.claudeArgs.length) { + flagArgs.push(opts.claudeArgs[i + 1]!); + i++; + } + continue; + } + positionalArgs.push(arg); + } + } + // Add hook settings for session tracking (when available) if (opts.hookSettingsPath) { args.push('--settings', opts.hookSettingsPath); logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`); } - // Add custom Claude arguments LAST (so prompt/slash commands are at the end) - if (opts.claudeArgs) { - args.push(...opts.claudeArgs) + // Add flag arguments before positional prompts. + if (flagArgs.length > 0) { + args.push(...flagArgs); + } + if (positionalArgs.length > 0) { + args.push(...positionalArgs); } // Prepare environment variables // Note: Local mode uses global Claude installation with --session-id flag // Launcher only intercepts fetch for thinking state tracking - const env: NodeJS.ProcessEnv = { + const env: NodeJS.ProcessEnv = stripNestedSessionDetectionEnv({ ...process.env, ...opts.claudeEnvVars, // Keep behavior consistent with our wrapper script. DISABLE_AUTOUPDATER: '1', - } + }) const resolvedClaudeCliPath = resolveClaudeCliPath(); const shouldUseNodeLauncher = isClaudeCliJavaScriptFile(resolvedClaudeCliPath); @@ -270,12 +309,14 @@ export async function claudeLocal(opts: { signal: opts.abort, cwd: opts.path, env, + windowsHide: true, }) : spawn(resolvedClaudeCliPath, args, { stdio: ['inherit', 'inherit', 'inherit', 'ignore'], signal: opts.abort, cwd: opts.path, env, + windowsHide: true, }); // Forward signals to child process to prevent orphaned processes diff --git a/apps/cli/src/backends/claude/cli/command.ts b/apps/cli/src/backends/claude/cli/command.ts index e50d9bf62..fe453e29d 100644 --- a/apps/cli/src/backends/claude/cli/command.ts +++ b/apps/cli/src/backends/claude/cli/command.ts @@ -217,7 +217,7 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} // Run claude --help and display its output try { - const claudeHelp = execFileSync(claudeCliPath, ['--help'], { encoding: 'utf8' }); + const claudeHelp = execFileSync(claudeCliPath, ['--help'], { encoding: 'utf8', windowsHide: true }); console.log(claudeHelp); } catch { console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')); diff --git a/apps/cli/src/backends/claude/localPermissions/localPermissionBridge.ts b/apps/cli/src/backends/claude/localPermissions/localPermissionBridge.ts index 8f467f5f2..0fefdd471 100644 --- a/apps/cli/src/backends/claude/localPermissions/localPermissionBridge.ts +++ b/apps/cli/src/backends/claude/localPermissions/localPermissionBridge.ts @@ -322,6 +322,7 @@ export class ClaudeLocalPermissionBridge { sessionId: this.session.client.sessionId, permissionId: params.requestId, toolName: getToolName(params.toolName), + permissionMode: this.session.lastPermissionMode, }); } catch (error) { logger.debug('[claude-local-permissions] Failed to broadcast permission request', error); diff --git a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts index aff0eb070..b72654312 100644 --- a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts +++ b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts @@ -84,6 +84,7 @@ vi.mock('@/ui/logger', () => ({ debugLargeJson: vi.fn(), infoDeveloper: vi.fn(), warn: vi.fn(), + getLogPath: vi.fn(() => '/tmp/happier.log'), logFilePath: '/tmp/happier.log', }, })); @@ -184,13 +185,17 @@ describe('runClaude fast-start', () => { return undefined as never; }) as any); - beforeAll(() => { + beforeAll(async () => { process.env.HAPPIER_STARTUP_TIMING_ENABLED = '1'; + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); }); - afterAll(() => { + afterAll(async () => { if (prevTiming === undefined) delete process.env.HAPPIER_STARTUP_TIMING_ENABLED; else process.env.HAPPIER_STARTUP_TIMING_ENABLED = prevTiming; + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); exitSpy.mockRestore(); }); diff --git a/apps/cli/src/backends/claude/sdk/query.executableResolution.test.ts b/apps/cli/src/backends/claude/sdk/query.executableResolution.test.ts index 303ab964d..86e0ddeb8 100644 --- a/apps/cli/src/backends/claude/sdk/query.executableResolution.test.ts +++ b/apps/cli/src/backends/claude/sdk/query.executableResolution.test.ts @@ -158,4 +158,49 @@ describe('claude sdk query executable resolution', () => { const spawnOpts = spawnMock.mock.calls[0]?.[2] as Record | undefined; expect(spawnOpts?.shell).toBe(true); }); + + it('strips nested Claude Code env vars from the spawned process environment', async () => { + const prevClaudeCode = process.env.CLAUDECODE; + const prevEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT; + process.env.CLAUDECODE = '1'; + process.env.CLAUDE_CODE_ENTRYPOINT = 'parent'; + + const spawnMock = vi.fn((..._args: any[]) => { + throw new Error('spawn invoked'); + }); + + vi.doMock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { ...actual, spawn: spawnMock }; + }); + + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { ...actual, existsSync: () => true }; + }); + + const { query } = (await import('./query')) as typeof import('./query'); + + try { + expect(() => + query({ + prompt: 'hi', + options: { + cwd: '/tmp', + pathToClaudeCodeExecutable: '/tmp/fake-claude.cjs', + }, + }), + ).toThrow(/spawn invoked/); + + expect(spawnMock).toHaveBeenCalled(); + const spawnOpts = spawnMock.mock.calls[0]?.[2] as Record | undefined; + expect(spawnOpts?.env?.CLAUDECODE).toBeUndefined(); + expect(spawnOpts?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + } finally { + if (typeof prevClaudeCode === 'string') process.env.CLAUDECODE = prevClaudeCode; + else delete process.env.CLAUDECODE; + if (typeof prevEntrypoint === 'string') process.env.CLAUDE_CODE_ENTRYPOINT = prevEntrypoint; + else delete process.env.CLAUDE_CODE_ENTRYPOINT; + } + }); }); diff --git a/apps/cli/src/backends/claude/sdk/query.ts b/apps/cli/src/backends/claude/sdk/query.ts index 07e8fe548..555e2c09b 100644 --- a/apps/cli/src/backends/claude/sdk/query.ts +++ b/apps/cli/src/backends/claude/sdk/query.ts @@ -26,6 +26,7 @@ import { getDefaultClaudeCodePath, getCleanEnv, logDebug, streamToStdin } from ' import type { Writable } from 'node:stream' import { logger } from '@/ui/logger' import { createManagedChildProcess } from '@/subprocess/supervision/managedChildProcess' +import { stripNestedSessionDetectionEnv } from '@/utils/processEnv/stripNestedSessionDetectionEnv' /** * Query class manages Claude Code process interaction @@ -346,10 +347,7 @@ export function query(config: { // Spawn Claude Code process // Use clean env for global claude to avoid local node_modules/.bin taking precedence const baseEnv = isCommandOnly ? getCleanEnv() : process.env - const spawnEnv: NodeJS.ProcessEnv = { ...baseEnv, ...envOverlay } - if (!spawnEnv.CLAUDE_CODE_ENTRYPOINT) { - spawnEnv.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts' - } + const spawnEnv: NodeJS.ProcessEnv = stripNestedSessionDetectionEnv({ ...baseEnv, ...envOverlay }) logDebug(`Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(' ')} (using ${isCommandOnly ? 'clean' : 'normal'} env)`) const lowerSpawnCommand = typeof spawnCommand === 'string' ? spawnCommand.toLowerCase() : ''; @@ -366,6 +364,7 @@ export function query(config: { // Use a shell on Windows only when needed to execute command-only or shell-script entrypoints. // Avoid shell for native binaries to reduce quoting and spawn-surface variability. shell: shouldUseShell, + windowsHide: true, }) as ChildProcessWithoutNullStreams const managedChild = createManagedChildProcess(child) diff --git a/apps/cli/src/backends/claude/sdk/utils.ts b/apps/cli/src/backends/claude/sdk/utils.ts index 81d51ab80..44b15ad50 100644 --- a/apps/cli/src/backends/claude/sdk/utils.ts +++ b/apps/cli/src/backends/claude/sdk/utils.ts @@ -9,6 +9,7 @@ import { execSync } from 'node:child_process' import { homedir } from 'node:os' import { logger } from '@/ui/logger' import { isBun } from '@/utils/runtime' +import { stripNestedSessionDetectionEnv } from '@/utils/processEnv/stripNestedSessionDetectionEnv' function resolveHomeDir(): string { // Prefer env overrides so unit tests and sandboxed runtimes can control the home directory. @@ -28,11 +29,12 @@ function resolveHomeDir(): string { function getGlobalClaudeVersion(): string | null { try { const cleanEnv = getCleanEnv() - const output = execSync('claude --version', { - encoding: 'utf8', + const output = execSync('claude --version', { + encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], cwd: resolveHomeDir(), - env: cleanEnv + env: cleanEnv, + windowsHide: true, }).trim() // Output format: "2.0.54 (Claude Code)" or similar const match = output.match(/(\d+\.\d+\.\d+)/) @@ -81,7 +83,7 @@ export function getCleanEnv(): NodeJS.ProcessEnv { logger.debug('[Claude SDK] Removed Bun-specific environment variables for Node.js compatibility') } - return env + return stripNestedSessionDetectionEnv(env) } /** @@ -96,11 +98,12 @@ function findGlobalClaudePath(): string | null { // PRIMARY: Check if 'claude' command works directly from home dir with clean PATH try { - execSync('claude --version', { - encoding: 'utf8', + execSync('claude --version', { + encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], cwd: homeDir, - env: cleanEnv + env: cleanEnv, + windowsHide: true, }) logger.debug('[Claude SDK] Global claude command available (checked with clean PATH)') return 'claude' @@ -111,11 +114,12 @@ function findGlobalClaudePath(): string | null { // FALLBACK for Unix: try which to get actual path if (process.platform !== 'win32') { try { - const result = execSync('which claude', { - encoding: 'utf8', + const result = execSync('which claude', { + encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], cwd: homeDir, - env: cleanEnv + env: cleanEnv, + windowsHide: true, }).trim() if (result && existsSync(result)) { logger.debug(`[Claude SDK] Found global claude path via which: ${result}`) diff --git a/apps/cli/src/backends/claude/utils/permissionHandler.ts b/apps/cli/src/backends/claude/utils/permissionHandler.ts index 660fcf118..af6c9a779 100644 --- a/apps/cli/src/backends/claude/utils/permissionHandler.ts +++ b/apps/cli/src/backends/claude/utils/permissionHandler.ts @@ -518,7 +518,7 @@ export class PermissionHandler { this.onPermissionRequestCallback(id); } - // Send push notification (best-effort; gated by per-account preferences). + // Send push notification (best-effort; gated by per-account preferences and permission mode). if (this.session.pushSender) { try { sendPermissionRequestPushNotificationForActiveAccount({ @@ -526,6 +526,7 @@ export class PermissionHandler { sessionId: this.session.client.sessionId, permissionId: id, toolName: getToolName(toolName), + permissionMode: this.permissionMode, }); } catch { // ignore diff --git a/apps/cli/src/backends/claude/utils/sdkToLogConverter.ts b/apps/cli/src/backends/claude/utils/sdkToLogConverter.ts index 03d1e734e..187b35cd0 100644 --- a/apps/cli/src/backends/claude/utils/sdkToLogConverter.ts +++ b/apps/cli/src/backends/claude/utils/sdkToLogConverter.ts @@ -34,7 +34,8 @@ function getGitBranch(cwd: string): string | undefined { const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd, encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'] + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, }).trim() return branch || undefined } catch { diff --git a/apps/cli/src/backends/codex/acp/backend.test.ts b/apps/cli/src/backends/codex/acp/backend.test.ts index c35862c47..dd8319eef 100644 --- a/apps/cli/src/backends/codex/acp/backend.test.ts +++ b/apps/cli/src/backends/codex/acp/backend.test.ts @@ -151,4 +151,33 @@ describe('createCodexAcpBackend', () => { await rm(homeDir, { recursive: true, force: true }); } }); + + it('uses a longer init timeout when codex ACP is resolved via npx fallback', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'happier-home-')); + const captured: Array = []; + try { + await withEnv({ + HAPPIER_VARIANT: 'stable', + HAPPIER_HOME_DIR: homeDir, + HAPPIER_CODEX_ACP_NPX_MODE: 'force', + }, async () => { + vi.doMock('@/agent/acp/AcpBackend', () => ({ + AcpBackend: class { + constructor(opts: any) { + captured.push(opts); + } + }, + })); + + const mod = await import('./backend'); + mod.createCodexAcpBackend({ cwd: homeDir, env: {} }); + + expect(captured).toHaveLength(1); + expect(captured[0].command).toBe('npx'); + expect(captured[0].transportHandler?.getInitTimeout?.()).toBeGreaterThan(60_000); + }); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/cli/src/backends/codex/acp/backend.ts b/apps/cli/src/backends/codex/acp/backend.ts index e0c1ac7c0..62e864c13 100644 --- a/apps/cli/src/backends/codex/acp/backend.ts +++ b/apps/cli/src/backends/codex/acp/backend.ts @@ -7,6 +7,7 @@ import { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from '@/agent/acp/AcpBackend'; import type { AgentBackend, AgentFactoryOptions, McpServerConfig } from '@/agent/core'; +import { DefaultTransport } from '@/agent/transport/DefaultTransport'; import { resolveCodexAcpSpawn, type SpawnSpec } from '@/backends/codex/acp/resolveCommand'; import type { PermissionMode } from '@/api/types'; @@ -25,6 +26,35 @@ export interface CodexAcpBackendResult { spawn: SpawnSpec; } +function readPositiveIntEnv(name: string): number | null { + const raw = typeof process.env[name] === 'string' ? process.env[name]!.trim() : ''; + if (!raw) return null; + const n = Number(raw); + if (!Number.isFinite(n)) return null; + if (!Number.isInteger(n)) return null; + if (n <= 0) return null; + return n; +} + +function resolveCodexAcpInitTimeoutMs(spawn: SpawnSpec): number { + const base = readPositiveIntEnv('HAPPIER_CODEX_ACP_INIT_TIMEOUT_MS'); + const npx = readPositiveIntEnv('HAPPIER_CODEX_ACP_NPX_INIT_TIMEOUT_MS'); + if (spawn.command === 'npx') { + return npx ?? base ?? 180_000; + } + return base ?? 60_000; +} + +class CodexAcpTransport extends DefaultTransport { + constructor(private readonly initTimeoutMs: number) { + super('codex'); + } + + override getInitTimeout(): number { + return this.initTimeoutMs; + } +} + export function createCodexAcpBackend(options: CodexAcpBackendOptions): CodexAcpBackendResult { const spawn = resolveCodexAcpSpawn({ permissionMode: options.permissionMode }); @@ -44,6 +74,9 @@ export function createCodexAcpBackend(options: CodexAcpBackendOptions): CodexAcp env: options.env, mcpServers: options.mcpServers, permissionHandler: options.permissionHandler, + transportHandler: spawn.command === 'npx' + ? new CodexAcpTransport(resolveCodexAcpInitTimeoutMs(spawn)) + : undefined, authMethodId, }; diff --git a/apps/cli/src/backends/codex/acp/resolveCommand.test.ts b/apps/cli/src/backends/codex/acp/resolveCommand.test.ts index 297bebd7a..fb44b2b6c 100644 --- a/apps/cli/src/backends/codex/acp/resolveCommand.test.ts +++ b/apps/cli/src/backends/codex/acp/resolveCommand.test.ts @@ -7,9 +7,7 @@ import type { PermissionMode } from '@/api/types'; const ENV_KEYS = [ 'HAPPIER_CODEX_ACP_BIN', 'HAPPIER_CODEX_ACP_CONFIG_OVERRIDES', - 'HAPPY_CODEX_ACP_CONFIG_OVERRIDES', 'HAPPIER_HOME_DIR', - 'HAPPIER_CODEX_ACP_ALLOW_NPX', 'HAPPIER_CODEX_ACP_NPX_MODE', 'PATH', ] as const; @@ -17,9 +15,7 @@ const ENV_KEYS = [ const ORIGINAL_ENV: Record<(typeof ENV_KEYS)[number], string | undefined> = { HAPPIER_CODEX_ACP_BIN: process.env.HAPPIER_CODEX_ACP_BIN, HAPPIER_CODEX_ACP_CONFIG_OVERRIDES: process.env.HAPPIER_CODEX_ACP_CONFIG_OVERRIDES, - HAPPY_CODEX_ACP_CONFIG_OVERRIDES: process.env.HAPPY_CODEX_ACP_CONFIG_OVERRIDES, HAPPIER_HOME_DIR: process.env.HAPPIER_HOME_DIR, - HAPPIER_CODEX_ACP_ALLOW_NPX: process.env.HAPPIER_CODEX_ACP_ALLOW_NPX, HAPPIER_CODEX_ACP_NPX_MODE: process.env.HAPPIER_CODEX_ACP_NPX_MODE, PATH: process.env.PATH, }; @@ -138,7 +134,6 @@ describe.sequential('resolveCodexAcpSpawn', () => { const { dir } = await createFakeCodexAcpBinary(); process.env.HAPPIER_HOME_DIR = dir; delete process.env.HAPPIER_CODEX_ACP_BIN; - delete process.env.HAPPIER_CODEX_ACP_ALLOW_NPX; delete process.env.HAPPIER_CODEX_ACP_NPX_MODE; const pathDir = await mkdtemp(join(tmpdir(), 'happier-codex-acp-path-')); diff --git a/apps/cli/src/backends/codex/acp/resolveCommand.ts b/apps/cli/src/backends/codex/acp/resolveCommand.ts index e76cb94d8..efe2dd421 100644 --- a/apps/cli/src/backends/codex/acp/resolveCommand.ts +++ b/apps/cli/src/backends/codex/acp/resolveCommand.ts @@ -8,10 +8,6 @@ import { configuration } from '@/configuration'; export type SpawnSpec = { command: string; args: string[] }; export type ResolveCodexAcpSpawnOptions = { permissionMode?: PermissionMode }; -function isTruthyEnv(value: string | undefined): boolean { - return typeof value === 'string' && ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); -} - type NpxMode = 'auto' | 'never' | 'force'; function readCodexAcpNpxMode(): NpxMode { @@ -20,9 +16,6 @@ function readCodexAcpNpxMode(): NpxMode { : ''; if (raw === 'never' || raw === 'force' || raw === 'auto') return raw; - // Backward-compat: legacy allow flag. (Default is already auto.) - if (isTruthyEnv(process.env.HAPPIER_CODEX_ACP_ALLOW_NPX)) return 'auto'; - return 'auto'; } @@ -51,9 +44,7 @@ function readCodexAcpConfigOverrides(): string[] { const raw = typeof process.env.HAPPIER_CODEX_ACP_CONFIG_OVERRIDES === 'string' ? process.env.HAPPIER_CODEX_ACP_CONFIG_OVERRIDES - : typeof process.env.HAPPY_CODEX_ACP_CONFIG_OVERRIDES === 'string' - ? process.env.HAPPY_CODEX_ACP_CONFIG_OVERRIDES - : ''; + : ''; return raw .split('\n') .map((l) => l.trim()) diff --git a/apps/cli/src/backends/codex/acp/runtime.ts b/apps/cli/src/backends/codex/acp/runtime.ts index 0306cfa0b..1483e06fb 100644 --- a/apps/cli/src/backends/codex/acp/runtime.ts +++ b/apps/cli/src/backends/codex/acp/runtime.ts @@ -45,6 +45,7 @@ export function createCodexAcpRuntime(params: { sessionId: params.session.sessionId, permissionId, toolName, + permissionMode: params.getPermissionMode?.() ?? params.permissionMode, }); }, }, diff --git a/apps/cli/src/backends/codex/cli/capability.loadSession.e2e.test.ts b/apps/cli/src/backends/codex/cli/capability.loadSession.e2e.test.ts index f4a8a8070..18697ce11 100644 --- a/apps/cli/src/backends/codex/cli/capability.loadSession.e2e.test.ts +++ b/apps/cli/src/backends/codex/cli/capability.loadSession.e2e.test.ts @@ -98,6 +98,7 @@ describe('cli.codex capability (ACP)', () => { kimi: makeUnavailableCliEntry(), kilo: makeUnavailableCliEntry(), pi: makeUnavailableCliEntry(), + copilot: makeUnavailableCliEntry(), }, tmux: { available: false }, }, diff --git a/apps/cli/src/backends/codex/cli/checklists.ts b/apps/cli/src/backends/codex/cli/checklists.ts index ac1de151e..290c8e6f9 100644 --- a/apps/cli/src/backends/codex/cli/checklists.ts +++ b/apps/cli/src/backends/codex/cli/checklists.ts @@ -1,5 +1,5 @@ -import { CODEX_MCP_RESUME_DIST_TAG } from '@/capabilities/deps/codexMcpResume'; import type { AgentChecklistContributions } from '@/backends/types'; +import { CODEX_ACP_DEP_ID, CODEX_MCP_RESUME_DEP_ID, CODEX_MCP_RESUME_DIST_TAG } from '@happier-dev/protocol/installables'; export const checklists = { 'resume.codex': [ @@ -11,11 +11,10 @@ export const checklists = { // - `includeAcpCapabilities` so the UI can enable/disable resume correctly when `expCodexAcp` is enabled // - dep statuses so we can block with a helpful install prompt { id: 'cli.codex', params: { includeAcpCapabilities: true, includeLoginStatus: true } }, - { id: 'dep.codex-acp', params: { onlyIfInstalled: true, includeRegistry: true } }, + { id: CODEX_ACP_DEP_ID, params: { onlyIfInstalled: true, includeRegistry: true } }, { - id: 'dep.codex-mcp-resume', + id: CODEX_MCP_RESUME_DEP_ID, params: { includeRegistry: true, onlyIfInstalled: true, distTag: CODEX_MCP_RESUME_DIST_TAG }, }, ], } satisfies AgentChecklistContributions; - diff --git a/apps/cli/src/backends/codex/cli/extraCapabilities.installablesParity.test.ts b/apps/cli/src/backends/codex/cli/extraCapabilities.installablesParity.test.ts new file mode 100644 index 000000000..baba73842 --- /dev/null +++ b/apps/cli/src/backends/codex/cli/extraCapabilities.installablesParity.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { INSTALLABLES_CATALOG } from '@happier-dev/protocol/installables'; + +import { capabilities } from './extraCapabilities'; + +describe('codex extraCapabilities installables parity', () => { + it('includes capabilities for all protocol installable deps', () => { + const ids = capabilities.map((c) => c.descriptor.id); + expect(ids).toEqual(expect.arrayContaining(INSTALLABLES_CATALOG.map((e) => e.capabilityId))); + }); +}); + diff --git a/apps/cli/src/backends/codex/codexLocalLauncher.ts b/apps/cli/src/backends/codex/codexLocalLauncher.ts index a0373070a..12013978b 100644 --- a/apps/cli/src/backends/codex/codexLocalLauncher.ts +++ b/apps/cli/src/backends/codex/codexLocalLauncher.ts @@ -120,6 +120,7 @@ export async function codexLocalLauncher(opts: { const knownResumeId: { value: string | null } = { value: null }; const pendingMetadataSessionId: { value: string | null } = { value: null }; let lastMetadataPublishAttemptMs = 0; + let inFlightMetadataPublish: Promise | null = null; const debug = opts.debugMirroring === true; let exitReason: CodexLauncherResult | null = null; @@ -163,6 +164,49 @@ export async function codexLocalLauncher(opts: { ); }; + const publishPendingCodexSessionIdNow = async (): Promise => { + const pending = pendingMetadataSessionId.value; + if (!pending) return; + + if (inFlightMetadataPublish) { + await inFlightMetadataPublish.catch(() => undefined); + return; + } + + const attempt = (async () => { + try { + const metadataSnapshotGetter = (opts.session as unknown as { getMetadataSnapshot?: () => unknown }).getMetadataSnapshot; + const metadata = + typeof metadataSnapshotGetter === 'function' + ? (metadataSnapshotGetter.call(opts.session) as Record | null) + : null; + if (metadata && metadata.codexSessionId === pending) { + pendingMetadataSessionId.value = null; + return; + } + + lastMetadataPublishAttemptMs = Date.now(); + await Promise.resolve( + opts.session.updateMetadata((current) => ({ + ...current, + codexSessionId: pending, + })), + ); + } catch { + // Best-effort only; retry loop will keep trying. + } + })(); + + inFlightMetadataPublish = attempt; + try { + await attempt; + } finally { + if (inFlightMetadataPublish === attempt) { + inFlightMetadataPublish = null; + } + } + }; + const doSwitch = async (): Promise => { if (switchRequested) return; switchRequested = true; @@ -204,6 +248,7 @@ export async function codexLocalLauncher(opts: { cwd: opts.path, env: process.env, stdio: interactive ? 'inherit' : 'pipe', + windowsHide: true, }); const managedChild = createManagedChildProcess(child); child.once('error', (error) => { @@ -318,6 +363,7 @@ export async function codexLocalLauncher(opts: { } queueCodexSessionIdPublish(candidateFile.sessionMeta?.id); + await publishPendingCodexSessionIdNow(); maybePublishPendingCodexSessionId(); if (switchRequested) { @@ -340,7 +386,10 @@ export async function codexLocalLauncher(opts: { filePath: candidateFile.filePath, debug, session: opts.session, - onCodexSessionId: (id) => queueCodexSessionIdPublish(id), + onCodexSessionId: async (id) => { + queueCodexSessionIdPublish(id); + await publishPendingCodexSessionIdNow(); + }, }); await mirror.start(); diff --git a/apps/cli/src/backends/codex/daemon/spawnHooks.ts b/apps/cli/src/backends/codex/daemon/spawnHooks.ts index 17bdd4b18..3e8faeaba 100644 --- a/apps/cli/src/backends/codex/daemon/spawnHooks.ts +++ b/apps/cli/src/backends/codex/daemon/spawnHooks.ts @@ -92,7 +92,7 @@ export const codexDaemonSpawnHooks: DaemonSpawnHooks = { return { ok: false, errorMessage: - 'Codex ACP is enabled, but codex-acp is not installed and npx is not available. Install codex-acp from the Happier app (Machine details → Codex ACP), install Node.js/npm (for npx), or disable the experiment.', + 'Codex ACP is enabled, but codex-acp is not installed and npx is not available. Install codex-acp from the Happier app (Machine details → Installables), install Node.js/npm (for npx), or disable the experiment.', }; } @@ -103,13 +103,13 @@ export const codexDaemonSpawnHooks: DaemonSpawnHooks = { return { ok: false, errorMessage: - 'Codex ACP is enabled, but codex-acp is not installed (and npx fallback is disabled). Install codex-acp from the Happier app (Machine details → Codex ACP), add codex-acp to PATH, or disable the experiment.', + 'Codex ACP is enabled, but codex-acp is not installed (and npx fallback is disabled). Install codex-acp from the Happier app (Machine details → Installables), add codex-acp to PATH, or disable the experiment.', }; } return { ok: false, errorMessage: - 'Codex ACP is enabled, but codex-acp could not be resolved on PATH. Install codex-acp from the Happier app (Machine details → Codex ACP), add codex-acp to PATH, or disable the experiment.', + 'Codex ACP is enabled, but codex-acp could not be resolved on PATH. Install codex-acp from the Happier app (Machine details → Installables), add codex-acp to PATH, or disable the experiment.', }; } diff --git a/apps/cli/src/backends/codex/executionRuns/executionRunBackendFactory.ts b/apps/cli/src/backends/codex/executionRuns/executionRunBackendFactory.ts index dae61f748..208925383 100644 --- a/apps/cli/src/backends/codex/executionRuns/executionRunBackendFactory.ts +++ b/apps/cli/src/backends/codex/executionRuns/executionRunBackendFactory.ts @@ -1,11 +1,12 @@ import { createCodexAcpBackend } from '@/backends/codex/acp/backend'; import type { ExecutionRunBackendFactory } from '@/backends/executionRuns/types'; +import { permissionModeForExecutionRunPolicy } from '@/backends/executionRuns/permissionModeForExecutionRunPolicy'; export const executionRunBackendFactory: ExecutionRunBackendFactory = (opts) => { return createCodexAcpBackend({ cwd: opts.cwd, env: opts.isolation?.env, permissionHandler: opts.permissionHandler, - permissionMode: opts.permissionMode as any, + permissionMode: permissionModeForExecutionRunPolicy(opts.permissionMode), }).backend; }; diff --git a/apps/cli/src/backends/codex/localControl/__tests__/codexRolloutMirror.test.ts b/apps/cli/src/backends/codex/localControl/__tests__/codexRolloutMirror.test.ts index e6539c995..41cf1a1c9 100644 --- a/apps/cli/src/backends/codex/localControl/__tests__/codexRolloutMirror.test.ts +++ b/apps/cli/src/backends/codex/localControl/__tests__/codexRolloutMirror.test.ts @@ -50,7 +50,9 @@ describe('CodexRolloutMirror', () => { const mirror = new CodexRolloutMirror({ filePath, debug: false, - onCodexSessionId: (id) => codexSessionIds.push(id), + onCodexSessionId: (id) => { + codexSessionIds.push(id); + }, session: { sendUserTextMessage: (text: string) => userTexts.push(text), sendCodexMessage: (body: unknown) => codexBodies.push(body as CodexBody), @@ -103,6 +105,56 @@ describe('CodexRolloutMirror', () => { } }); + it('awaits codexSessionId publishing before processing later rollout lines', async () => { + const root = rememberTempDir(await mkdtemp(join(tmpdir(), 'codex-rollout-mirror-'))); + const filePath = join(root, 'rollout.jsonl'); + await writeFile( + filePath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sid' } }), + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'exec_command', arguments: '{\"cmd\":\"echo hi\"}', call_id: 'call_1' }, + }), + ].join('\n') + '\n', + 'utf8', + ); + + const codexBodies: CodexBody[] = []; + let resolvePublish!: () => void; + const publishPromise = new Promise((resolve) => { + resolvePublish = resolve; + }); + + const mirror = new CodexRolloutMirror({ + filePath, + debug: false, + onCodexSessionId: async () => { + await publishPromise; + }, + session: { + sendUserTextMessage: () => {}, + sendCodexMessage: (body: unknown) => codexBodies.push(body as CodexBody), + sendSessionEvent: () => {}, + } as any, + }); + + const startPromise = mirror.start(); + try { + // Mirror should not process subsequent lines until codexSessionId publishing completes. + expect(codexBodies.some((b) => b.type === 'tool-call')).toBe(false); + + resolvePublish(); + + await startPromise; + await waitFor(() => { + expect(codexBodies.some((b) => b.type === 'tool-call' && b.callId === 'call_1')).toBe(true); + }); + } finally { + await mirror.stop(); + } + }); + it('replays existing JSONL content when starting after lines already exist', async () => { const root = rememberTempDir(await mkdtemp(join(tmpdir(), 'codex-rollout-mirror-'))); const filePath = join(root, 'rollout.jsonl'); @@ -124,7 +176,9 @@ describe('CodexRolloutMirror', () => { const mirror = new CodexRolloutMirror({ filePath, debug: false, - onCodexSessionId: (id) => codexSessionIds.push(id), + onCodexSessionId: (id) => { + codexSessionIds.push(id); + }, session: { sendUserTextMessage: () => {}, sendCodexMessage: (body: unknown) => codexBodies.push(body as CodexBody), diff --git a/apps/cli/src/backends/codex/localControl/codexRolloutMirror.ts b/apps/cli/src/backends/codex/localControl/codexRolloutMirror.ts index 939e145d0..d9f2cfaa1 100644 --- a/apps/cli/src/backends/codex/localControl/codexRolloutMirror.ts +++ b/apps/cli/src/backends/codex/localControl/codexRolloutMirror.ts @@ -11,7 +11,7 @@ export class CodexRolloutMirror { filePath: string; session: ApiSessionClient; debug: boolean; - onCodexSessionId: (id: string) => void; + onCodexSessionId: (id: string) => void | Promise; }, ) {} @@ -36,11 +36,11 @@ export class CodexRolloutMirror { await follower?.stop(); } - private onJson(value: unknown): void { + private async onJson(value: unknown): Promise { const actions = mapCodexRolloutEventToActions(value, { debug: this.opts.debug }); for (const action of actions) { if (action.type === 'codex-session-id') { - this.opts.onCodexSessionId(action.id); + await this.opts.onCodexSessionId(action.id); continue; } if (action.type === 'user-text') { diff --git a/apps/cli/src/backends/codex/mcp/version.ts b/apps/cli/src/backends/codex/mcp/version.ts index 4315f6718..a23f4b98f 100644 --- a/apps/cli/src/backends/codex/mcp/version.ts +++ b/apps/cli/src/backends/codex/mcp/version.ts @@ -40,7 +40,7 @@ export function getCodexVersionInfo(codexCommand: string): CodexVersionInfo { if (cached) return cached; try { - const raw = execFileSync(codexCommand, ['--version'], { encoding: 'utf8' }).trim(); + const raw = execFileSync(codexCommand, ['--version'], { encoding: 'utf8', windowsHide: true }).trim(); const match = raw.match(/(?:codex(?:-cli)?)\s+v?(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)\.(\d+))?/i) ?? raw.match(/\b(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)\.(\d+))?\b/); if (!match) { diff --git a/apps/cli/src/backends/codex/resume/resolveCodexMcpServer.ts b/apps/cli/src/backends/codex/resume/resolveCodexMcpServer.ts index b8a6b73d4..2c29a2676 100644 --- a/apps/cli/src/backends/codex/resume/resolveCodexMcpServer.ts +++ b/apps/cli/src/backends/codex/resume/resolveCodexMcpServer.ts @@ -1,8 +1,43 @@ +import { existsSync } from 'node:fs'; +import { join, delimiter as pathDelimiter } from 'node:path'; + import { shouldUseCodexMcpResumeServer } from '../localControl/localControlSupport'; import { resolveCodexMcpResumeServerCommand } from './resolveMcpResumeServer'; export type CodexMcpServerSpawn = Readonly<{ mode: 'codex-cli' | 'mcp-server'; command: string }>; +/** + * Resolve the codex binary on PATH, respecting PATHEXT on Windows. + * + * Node.js `execFileSync('codex', ...)` does NOT try `.cmd`/`.exe` extensions, + * so on Windows we must resolve the full filename ourselves. + */ +function resolveCodexOnPath(): string { + const override = typeof process.env.HAPPIER_CODEX_PATH === 'string' + ? process.env.HAPPIER_CODEX_PATH.trim() + : ''; + if (override) return override; + + const pathEnv = typeof process.env.PATH === 'string' ? process.env.PATH : ''; + const isWindows = process.platform === 'win32'; + const extensions: string[] = isWindows + ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM') + .split(';') + .map((e: string) => e.trim()) + .filter(Boolean) + : ['']; + + for (const dir of pathEnv.split(pathDelimiter)) { + const trimmed = dir.trim(); + if (!trimmed) continue; + for (const ext of extensions) { + const candidate = join(trimmed, isWindows ? `codex${ext}` : 'codex'); + if (existsSync(candidate)) return candidate; + } + } + return 'codex'; +} + export async function resolveCodexMcpServerSpawn(opts: Readonly<{ useCodexAcp: boolean; experimentalCodexResumeEnabled: boolean; @@ -11,7 +46,7 @@ export async function resolveCodexMcpServerSpawn(opts: Readonly<{ }>): Promise { if (opts.useCodexAcp) { // ACP mode bypasses Codex MCP server selection (resume/no-resume). - return { mode: 'codex-cli', command: 'codex' }; + return { mode: 'codex-cli', command: resolveCodexOnPath() }; } const normalizedVendorResumeId = @@ -28,7 +63,7 @@ export async function resolveCodexMcpServerSpawn(opts: Readonly<{ }); if (!needsResumeServer) { - return { mode: 'codex-cli', command: 'codex' }; + return { mode: 'codex-cli', command: resolveCodexOnPath() }; } const command = (await resolveCodexMcpResumeServerCommand())?.trim() ?? null; diff --git a/apps/cli/src/backends/codex/resume/resolveMcpResumeServer.test.ts b/apps/cli/src/backends/codex/resume/resolveMcpResumeServer.test.ts index 9da947fb2..cbdbee55a 100644 --- a/apps/cli/src/backends/codex/resume/resolveMcpResumeServer.test.ts +++ b/apps/cli/src/backends/codex/resume/resolveMcpResumeServer.test.ts @@ -43,7 +43,7 @@ describe('resolveCodexMcpResumeServerCommand', () => { } }); - it('falls back to the legacy install bin when present', async () => { + it('does not fall back to the legacy install bin when present', async () => { const home = makeTempHomeDir(); const bin = join(home, 'tools', 'codex-resume', 'node_modules', '.bin', process.platform === 'win32' ? 'codex-mcp-resume.cmd' : 'codex-mcp-resume'); writeExecutable(bin); @@ -56,7 +56,28 @@ describe('resolveCodexMcpResumeServerCommand', () => { vi.resetModules(); const mod = await import('./resolveMcpResumeServer'); const resolved = await mod.resolveCodexMcpResumeServerCommand(); - expect(resolved).toBe(bin); + expect(resolved).toBeNull(); + }); + } finally { + cleanupTempDir(home); + } + }); + + it('ignores legacy HAPPIER_CODEX_RESUME_BIN env override', async () => { + const home = makeTempHomeDir(); + const legacyOverride = join(home, 'override', 'codex-mcp-resume'); + writeExecutable(legacyOverride); + + try { + await withResumeEnv({ + HAPPIER_HOME_DIR: home, + HAPPIER_CODEX_RESUME_MCP_SERVER_BIN: undefined, + HAPPIER_CODEX_RESUME_BIN: legacyOverride, + }, async () => { + vi.resetModules(); + const mod = await import('./resolveMcpResumeServer'); + const resolved = await mod.resolveCodexMcpResumeServerCommand(); + expect(resolved).toBeNull(); }); } finally { cleanupTempDir(home); diff --git a/apps/cli/src/backends/codex/resume/resolveMcpResumeServer.ts b/apps/cli/src/backends/codex/resume/resolveMcpResumeServer.ts index 8d274cc10..6939b66e1 100644 --- a/apps/cli/src/backends/codex/resume/resolveMcpResumeServer.ts +++ b/apps/cli/src/backends/codex/resume/resolveMcpResumeServer.ts @@ -5,7 +5,7 @@ import { getCodexMcpResumeDepStatus } from '@/capabilities/deps/codexMcpResume'; function readCodexMcpResumeEnvOverride(): string | null { const v = typeof process.env.HAPPIER_CODEX_RESUME_MCP_SERVER_BIN === 'string' ? process.env.HAPPIER_CODEX_RESUME_MCP_SERVER_BIN.trim() - : (typeof process.env.HAPPIER_CODEX_RESUME_BIN === 'string' ? process.env.HAPPIER_CODEX_RESUME_BIN.trim() : ''); + : ''; return v || null; } diff --git a/apps/cli/src/backends/codex/runCodex.acpResumePreflight.integration.test.ts b/apps/cli/src/backends/codex/runCodex.acpResumePreflight.integration.test.ts new file mode 100644 index 000000000..9c2003ca4 --- /dev/null +++ b/apps/cli/src/backends/codex/runCodex.acpResumePreflight.integration.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Credentials } from '@/persistence'; + +const probeCodexAcpLoadSessionSupportSpy = vi.fn<(...args: any[]) => Promise>(async (..._args) => { + throw new Error('probe-called'); +}); +vi.mock('@/backends/codex/acp/probeLoadSessionSupport', () => ({ + probeCodexAcpLoadSessionSupport: (...args: any[]) => probeCodexAcpLoadSessionSupportSpy(...args), +})); + +const createHappierMcpBridgeSpy = vi.fn<(...args: any[]) => Promise>(async (..._args) => { + throw new Error('bridge-called'); +}); +vi.mock('@/agent/runtime/createHappierMcpBridge', () => ({ + createHappierMcpBridge: (...args: any[]) => createHappierMcpBridgeSpy(...args), +})); + +const createCodexAcpRuntimeSpy = vi.fn<(...args: any[]) => any>((..._args) => ({ + getSessionId: () => null, + supportsInFlightSteer: () => false, + isTurnInFlight: () => false, + beginTurn: vi.fn(), + cancel: vi.fn(async () => {}), + reset: vi.fn(async () => {}), + startOrLoad: vi.fn(() => Promise.reject(new Error('startOrLoad-called'))), + setSessionMode: vi.fn(async () => {}), + setSessionModel: vi.fn(async () => {}), + setSessionConfigOption: vi.fn(async () => {}), + steerPrompt: vi.fn(async () => {}), + sendPrompt: vi.fn(async () => {}), + flushTurn: vi.fn(), +})); +vi.mock('./acp/runtime', () => ({ + createCodexAcpRuntime: (...args: any[]) => createCodexAcpRuntimeSpy(...args), +})); + +let waitForMessagesOrPendingImpl: ((opts: any) => Promise) | null = null; +const waitForMessagesOrPendingSpy = vi.fn<(...args: any[]) => Promise>(async (opts: any) => { + if (waitForMessagesOrPendingImpl) return await waitForMessagesOrPendingImpl(opts); + return null; +}); +vi.mock('@/agent/runtime/waitForMessagesOrPending', () => ({ + waitForMessagesOrPending: (...args: any[]) => waitForMessagesOrPendingSpy(...args), +})); + +vi.mock('@/agent/runtime/runtimeOverridesSynchronizer', () => ({ + initializeRuntimeOverridesSynchronizer: vi.fn(async () => ({ + syncFromMetadata: vi.fn(), + seedFromSession: vi.fn(async () => {}), + })), +})); + +vi.mock('@/agent/runtime/modelOverrideSync', () => ({ + createModelOverrideSynchronizer: vi.fn(() => ({ + syncFromMetadata: vi.fn(), + flushPendingAfterStart: vi.fn(async () => {}), + })), +})); + +vi.mock('@/backends/codex/utils/metadataOverridesWatcher', () => ({ + runMetadataOverridesWatcherLoop: vi.fn(), +})); + +vi.mock('@/agent/runtime/startup/startupOverridesCache', () => ({ + readStartupOverridesCacheForBackend: vi.fn(() => null), + writeStartupOverridesCacheForBackend: vi.fn(() => {}), +})); + +vi.mock('./runtime/createCodexRemoteTerminalUi', () => ({ + createCodexRemoteTerminalUi: vi.fn(() => ({ + mount: vi.fn(), + unmount: vi.fn(async () => {}), + setAllowSwitchToLocal: vi.fn(), + })), +})); + +vi.mock('@/ui/tty/resolveHasTTY', () => ({ + resolveHasTTY: vi.fn(() => false), +})); + +vi.mock('@/backends/codex/experiments', () => ({ + isExperimentalCodexAcpEnabled: vi.fn(() => true), + isExperimentalCodexVendorResumeEnabled: vi.fn(() => false), +})); + +vi.mock('./utils/resolveCodexStartingMode', () => ({ + resolveCodexStartingMode: vi.fn(() => 'remote'), +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + debugLargeJson: vi.fn(), + infoDeveloper: vi.fn(), + warn: vi.fn(), + getLogPath: vi.fn(() => '/tmp/happier.log'), + logFilePath: '/tmp/happier.log', + }, +})); + +vi.mock('@/ui/doctor', () => ({ + getEnvironmentInfo: vi.fn(() => ({})), +})); + +vi.mock('@/api/offline/serverConnectionErrors', () => ({ + connectionState: { setBackend: vi.fn(), notifyOffline: vi.fn() }, +})); + +vi.mock('@/integrations/caffeinate', () => ({ + stopCaffeinate: vi.fn(), +})); + +vi.mock('@/rpc/handlers/killSession', () => ({ + registerKillSessionHandler: vi.fn(), +})); + +vi.mock('./utils/createCodexPermissionHandler', () => ({ + createCodexPermissionHandler: vi.fn(() => ({ + reset: vi.fn(), + updateSession: vi.fn(), + handleToolCall: vi.fn(async () => ({ decision: 'approved' })), + })), +})); + +vi.mock('./utils/applyPermissionModeToHandler', () => ({ + applyPermissionModeToCodexPermissionHandler: vi.fn(), +})); + +vi.mock('./localControl/createLocalControlSupportResolver', () => ({ + createCodexLocalControlSupportResolver: vi.fn(() => async () => ({ ok: false as const, reason: 'test' })), +})); + +vi.mock('@/agent/runtime/initializeBackendApiContext', () => ({ + initializeBackendApiContext: vi.fn(async () => ({ + api: { + getOrCreateSession: vi.fn(async () => ({ id: 'sess_1', metadataVersion: 1 })), + sessionSyncClient: vi.fn(() => ({ + sessionId: 'sess_1', + rpcHandlerManager: { registerHandler: vi.fn(), invokeLocal: vi.fn() }, + ensureMetadataSnapshot: vi.fn(async () => ({})), + getMetadataSnapshot: vi.fn(() => ({})), + onUserMessage: vi.fn(), + sendSessionEvent: vi.fn(), + updateMetadata: vi.fn(), + updateAgentState: vi.fn(async () => {}), + keepAlive: vi.fn(), + sendSessionDeath: vi.fn(), + flush: vi.fn(async () => {}), + close: vi.fn(async () => {}), + popPendingMessage: vi.fn(async () => false), + waitForMetadataUpdate: vi.fn(async () => false), + })), + push: vi.fn(() => ({ sendToAllDevices: vi.fn() })), + }, + machineId: 'machine_1', + })), +})); + +const initializeBackendRunSessionSpy = vi.fn(async (opts: any) => { + const session = opts.api.sessionSyncClient({ id: 'sess_1', metadataVersion: 1 }); + // Ensure optional methods exist for codepaths that may call them during startup. + Object.assign(session, { + fetchLatestUserPermissionIntentFromTranscript: vi.fn(async () => null), + sendCodexMessage: vi.fn(), + sendAgentMessage: vi.fn(), + }); + return { + session, + reconnectionHandle: null, + reportedSessionId: 'sess_1', + attachedToExistingSession: false, + }; +}); +vi.mock('@/agent/runtime/initializeBackendRunSession', () => ({ + initializeBackendRunSession: (opts: any) => initializeBackendRunSessionSpy(opts), +})); + +describe('runCodex CodexACP resume behavior', () => { + beforeEach(() => { + probeCodexAcpLoadSessionSupportSpy.mockReset(); + createHappierMcpBridgeSpy.mockReset(); + createCodexAcpRuntimeSpy.mockClear(); + waitForMessagesOrPendingSpy.mockClear(); + waitForMessagesOrPendingImpl = null; + }); + + it('does not probe Codex ACP capabilities during startup for --resume sessions', async () => { + probeCodexAcpLoadSessionSupportSpy.mockImplementationOnce(async () => { + throw new Error('probe-called'); + }); + createHappierMcpBridgeSpy.mockImplementationOnce(async () => { + throw new Error('bridge-called'); + }); + + const { runCodex } = await import('./runCodex'); + + const credentials = { token: 'test' } as Credentials; + await expect( + runCodex({ + credentials, + startedBy: 'terminal', + startingMode: 'remote', + resume: 'resume-123', + permissionMode: 'default', + permissionModeUpdatedAt: 1, + } as any), + ).rejects.toThrow(/bridge-called/); + }); + + it('fails closed for explicit --resume when Codex ACP loadSession fails', async () => { + probeCodexAcpLoadSessionSupportSpy.mockImplementationOnce(async () => ({ ok: true, checkedAt: Date.now(), loadSession: true, agentCapabilities: { loadSession: true, sessionCapabilities: {}, promptCapabilities: { image: false, audio: false, embeddedContext: false }, mcpCapabilities: { http: false, sse: false } } } as any)); + createHappierMcpBridgeSpy.mockImplementationOnce(async () => ({ + happierMcpServer: { url: 'http://127.0.0.1:0', stop: vi.fn() }, + mcpServers: {}, + })); + + // Feed a single message so the runner attempts to start/load the ACP session. + let delivered = false; + waitForMessagesOrPendingImpl = async () => { + if (delivered) return null; + delivered = true; + return { + message: 'hello', + mode: { permissionMode: 'default', permissionModeUpdatedAt: 1, localId: null, model: null }, + isolate: false, + hash: 'hash', + }; + }; + + const { runCodex } = await import('./runCodex'); + + const credentials = { token: 'test' } as Credentials; + const outcome = await runCodex({ + credentials, + startedBy: 'terminal', + startingMode: 'remote', + resume: 'resume-123', + permissionMode: 'default', + permissionModeUpdatedAt: 1, + } as any) + .then(() => ({ ok: true as const })) + .catch((error: unknown) => ({ ok: false as const, error })); + + expect(createCodexAcpRuntimeSpy).toHaveBeenCalled(); + expect(waitForMessagesOrPendingSpy).toHaveBeenCalled(); + const createdRuntime = createCodexAcpRuntimeSpy.mock.results[0]?.value as any; + const startOrLoad = createdRuntime?.startOrLoad as ReturnType | undefined; + expect(startOrLoad).toBeTruthy(); + expect(startOrLoad?.mock.calls.length).toBe(1); + expect(startOrLoad?.mock.calls[0]?.[0]).toMatchObject({ resumeId: 'resume-123' }); + await expect(startOrLoad?.mock.results?.[0]?.value).rejects.toThrow(/startOrLoad-called/); + + expect(outcome.ok).toBe(false); + }); +}); diff --git a/apps/cli/src/backends/codex/runCodex.fastStart.integration.test.ts b/apps/cli/src/backends/codex/runCodex.fastStart.integration.test.ts index 117379c9c..e7b78ba80 100644 --- a/apps/cli/src/backends/codex/runCodex.fastStart.integration.test.ts +++ b/apps/cli/src/backends/codex/runCodex.fastStart.integration.test.ts @@ -95,6 +95,7 @@ vi.mock('@/ui/logger', () => ({ debugLargeJson: vi.fn(), infoDeveloper: vi.fn(), warn: vi.fn(), + getLogPath: vi.fn(() => '/tmp/happier.log'), logFilePath: '/tmp/happier.log', }, })); @@ -137,6 +138,8 @@ describe('runCodex fast-start', () => { it('invokes local TUI spawn without waiting for backend API initialization', async () => { const prevTiming = process.env.HAPPIER_STARTUP_TIMING_ENABLED; process.env.HAPPIER_STARTUP_TIMING_ENABLED = '1'; + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); const { runCodex } = await import('./runCodex'); const { logger } = await import('@/ui/logger'); @@ -159,6 +162,7 @@ describe('runCodex fast-start', () => { if (prevTiming === undefined) delete process.env.HAPPIER_STARTUP_TIMING_ENABLED; else process.env.HAPPIER_STARTUP_TIMING_ENABLED = prevTiming; + reloadConfiguration(); } const debugCalls = (logger.debug as any).mock?.calls?.map((c: any[]) => c[0]) ?? []; @@ -215,6 +219,8 @@ describe('runCodex fast-start', () => { it('does not attach the deferred session to an offline stub; flushes buffered writes only after reconnection swap', async () => { const prevTiming = process.env.HAPPIER_STARTUP_TIMING_ENABLED; process.env.HAPPIER_STARTUP_TIMING_ENABLED = '1'; + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); const offlineStubCalls: Array<'sendSessionEvent' | 'updateMetadata'> = []; const offlineStub = { @@ -345,6 +351,7 @@ describe('runCodex fast-start', () => { await runPromise; if (prevTiming === undefined) delete process.env.HAPPIER_STARTUP_TIMING_ENABLED; else process.env.HAPPIER_STARTUP_TIMING_ENABLED = prevTiming; + reloadConfiguration(); } if (testError) { diff --git a/apps/cli/src/backends/codex/runCodex.ts b/apps/cli/src/backends/codex/runCodex.ts index 514091628..cb05af909 100644 --- a/apps/cli/src/backends/codex/runCodex.ts +++ b/apps/cli/src/backends/codex/runCodex.ts @@ -49,11 +49,9 @@ import { } from './localControl/localControlSupport'; import { createCodexLocalControlSupportResolver } from './localControl/createLocalControlSupportResolver'; import { resolveCodexMcpServerSpawn } from './resume/resolveCodexMcpServer'; -import { probeCodexAcpLoadSessionSupport } from './acp/probeLoadSessionSupport'; import { resolveCodexMessageModel } from './utils/resolveCodexMessageModel'; import { buildCodexMcpStartConfigForMessage } from './utils/buildCodexMcpStartConfigForMessage'; import { createModelOverrideSynchronizer } from '@/agent/runtime/modelOverrideSync'; -import { resolveCodexAcpResumePreflight } from './utils/codexAcpResumePreflight'; import { resolveCodexMcpPolicyForPermissionMode } from './utils/permissionModePolicy'; import { createCodexMcpMessageHandler, @@ -163,6 +161,9 @@ export async function runCodex(opts: { const timing = createStartupTiming({ enabled: configuration.startupTimingEnabled, nowMs }); const resumeIdFromArgs = typeof opts.resume === 'string' && opts.resume.trim().length > 0 ? opts.resume.trim() : null; + // If the user explicitly provided --resume, fail closed for that specific resume id. + // Once the explicit resume succeeds, subsequent best-effort resume attempts (e.g. after abort) may fall back. + let strictResumeIdForRun: string | null = resumeIdFromArgs; let permissionModeSeededFromCache = false; if (resumeIdFromArgs && typeof opts.permissionMode !== 'string') { const cached = readStartupOverridesCacheForBackend({ @@ -757,26 +758,6 @@ export async function runCodex(opts: { // Start Context // - if (useCodexAcp) { - const resumeId = storedSessionIdForResume?.trim(); - if (resumeId) { - const stopProbeSpan = timing.startSpan('codex_acp_probe'); - const probe = await probeCodexAcpLoadSessionSupport({ signal: abortController.signal }); - stopProbeSpan(); - const preflight = resolveCodexAcpResumePreflight({ - resumeId, - probe: probe.ok - ? { ok: true, loadSessionSupported: probe.loadSession } - : { ok: false, errorMessage: probe.error.message }, - }); - if (!preflight.ok) { - messageBuffer.addMessage(preflight.errorMessage, 'status'); - session.sendSessionEvent({ type: 'message', message: preflight.errorMessage }); - throw new Error(preflight.errorMessage); - } - } - } - // Start Happier MCP server (HTTP) and prepare STDIO bridge config for Codex const happierBridge = await createHappierMcpBridge(session, { commandMode: 'current-process' }); happierMcpServer = happierBridge.happierMcpServer; @@ -1106,12 +1087,30 @@ export async function runCodex(opts: { messageBuffer.addMessage('Resuming previous context…', 'status'); try { await codexAcp.startOrLoad({ resumeId, importHistory: storedSessionIdFromLocalControl !== true }); + if (strictResumeIdForRun && resumeId === strictResumeIdForRun) { + strictResumeIdForRun = null; + } storedSessionIdForResume = nextStoredSessionIdForResumeAfterAttempt(storedSessionIdForResume, { attempted: true, success: true, }); storedSessionIdFromLocalControl = false; } catch (e) { + const isStrict = Boolean(strictResumeIdForRun && resumeId === strictResumeIdForRun); + if (isStrict) { + const reason = formatErrorForUi(e); + const message = + `Failed to resume this Codex ACP session (${resumeId}).\n` + + `Reason: ${reason}\n` + + `Fix: install/enable a Codex ACP build that supports resume (loadSession), or disable Codex ACP and use Codex MCP resume.\n` + + `Note: Happier refuses to start a new Codex session when --resume was requested.`; + messageBuffer.addMessage(message, 'status'); + session.sendSessionEvent({ type: 'message', message }); + const err = new Error(message); + err.name = 'CodexAcpResumeError'; + throw err; + } + logger.debug('[Codex ACP] Resume failed; starting a new session instead', e); messageBuffer.addMessage('Resume failed; starting a new session.', 'status'); session.sendSessionEvent({ type: 'message', message: 'Resume failed; starting a new session.' }); @@ -1238,6 +1237,11 @@ export async function runCodex(opts: { } catch (error) { logger.warn('Error in codex session:', error); const isAbortError = error instanceof Error && error.name === 'AbortError'; + const isResumeError = error instanceof Error && error.name === 'CodexAcpResumeError'; + + if (isResumeError) { + throw error; + } if (isAbortError) { messageBuffer.addMessage('Aborted by user', 'status'); diff --git a/apps/cli/src/backends/codex/utils/codexAcpResumePreflight.test.ts b/apps/cli/src/backends/codex/utils/codexAcpResumePreflight.test.ts deleted file mode 100644 index 6cd6f8461..000000000 --- a/apps/cli/src/backends/codex/utils/codexAcpResumePreflight.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { resolveCodexAcpResumePreflight } from './codexAcpResumePreflight'; - -describe('Codex ACP resume preflight', () => { - it('returns ok when no resumeId is provided', () => { - expect(resolveCodexAcpResumePreflight({ - resumeId: null, - probe: { ok: true, loadSessionSupported: false }, - })).toEqual({ ok: true }); - }); - - it('returns ok when resumeId is whitespace-only, even if probe fails', () => { - expect(resolveCodexAcpResumePreflight({ - resumeId: ' ', - probe: { ok: false, errorMessage: 'spawn failed' }, - })).toEqual({ ok: true }); - }); - - it('returns ok when loadSession is supported', () => { - expect(resolveCodexAcpResumePreflight({ - resumeId: 'abc', - probe: { ok: true, loadSessionSupported: true }, - })).toEqual({ ok: true }); - }); - - it('returns an actionable error when loadSession is unsupported', () => { - const result = resolveCodexAcpResumePreflight({ - resumeId: 'abc', - probe: { ok: true, loadSessionSupported: false }, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errorMessage).toContain('Codex ACP'); - expect(result.errorMessage).toContain('loadSession'); - expect(result.errorMessage).toContain('--resume'); - } - }); - - it('returns an actionable error when the probe fails', () => { - const result = resolveCodexAcpResumePreflight({ - resumeId: 'abc', - probe: { ok: false, errorMessage: ' spawn failed ' }, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errorMessage).toContain('Codex ACP'); - expect(result.errorMessage).toContain('spawn failed'); - } - }); - - it('falls back to unknown error text when probe error is blank', () => { - const result = resolveCodexAcpResumePreflight({ - resumeId: 'abc', - probe: { ok: false, errorMessage: ' ' }, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errorMessage).toContain('Unknown error'); - } - }); -}); diff --git a/apps/cli/src/backends/codex/utils/codexAcpResumePreflight.ts b/apps/cli/src/backends/codex/utils/codexAcpResumePreflight.ts deleted file mode 100644 index ce35f644b..000000000 --- a/apps/cli/src/backends/codex/utils/codexAcpResumePreflight.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type CodexAcpResumePreflightProbe = - | Readonly<{ ok: true; loadSessionSupported: boolean }> - | Readonly<{ ok: false; errorMessage: string }>; - -export type CodexAcpResumePreflightResult = - | Readonly<{ ok: true }> - | Readonly<{ ok: false; errorMessage: string }>; - -function normalizeResumeId(resumeId: string | null): string { - const trimmed = typeof resumeId === 'string' ? resumeId.trim() : ''; - return trimmed; -} - -export function resolveCodexAcpResumePreflight(opts: Readonly<{ - resumeId: string | null; - probe: CodexAcpResumePreflightProbe; -}>): CodexAcpResumePreflightResult { - const resumeId = normalizeResumeId(opts.resumeId); - if (!resumeId) return { ok: true }; - - if (!opts.probe.ok) { - const errRaw = typeof opts.probe.errorMessage === 'string' ? opts.probe.errorMessage.trim() : ''; - const err = errRaw || 'Unknown error'; - return { - ok: false, - errorMessage: - `Cannot resume this Codex session in Codex ACP.\n` + - `Reason: failed to probe Codex ACP capabilities (${err}).\n` + - `Fix: disable the Codex ACP experiment or install/enable a Codex ACP build that supports resume.\n` + - `Note: Happy refuses to silently start a new Codex ACP session when --resume was requested.`, - }; - } - - if (!opts.probe.loadSessionSupported) { - return { - ok: false, - errorMessage: - `Cannot resume this Codex session in Codex ACP: this Codex ACP build does not support loadSession.\n` + - `Fix: disable the Codex ACP experiment (use Codex MCP + resume server) or install a Codex ACP build with loadSession support.\n` + - `Note: Happy refuses to silently start a new Codex ACP session when --resume was requested.`, - }; - } - - return { ok: true }; -} diff --git a/apps/cli/src/backends/copilot/acp/backend.test.ts b/apps/cli/src/backends/copilot/acp/backend.test.ts new file mode 100644 index 000000000..9728a9a33 --- /dev/null +++ b/apps/cli/src/backends/copilot/acp/backend.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +describe('copilot/acp/backend', () => { + it('builds stable AcpBackendOptions for Copilot ACP spawn', async () => { + vi.stubEnv('HAPPIER_COPILOT_PATH', undefined); + + const mod = await import('./backend'); + + const opts = mod.buildCopilotAcpBackendOptions({ + cwd: '/tmp', + env: { DEBUG: '1', NODE_ENV: 'development', FOO: 'bar' }, + permissionMode: 'bypassPermissions', + mcpServers: { test: { command: 'node', args: ['-e', 'console.log(1)'], env: { A: 'B' } } }, + permissionHandler: undefined, + }); + + expect(opts.agentName).toBe('copilot'); + expect(opts.cwd).toBe('/tmp'); + expect(opts.command).toBe('copilot'); + expect(opts.args).toEqual(['--acp', '--yolo']); + expect(opts.env).toEqual({ + NODE_ENV: 'development', + DEBUG: '1', + FOO: 'bar', + }); + expect(opts.transportHandler).toBeDefined(); + expect(opts.mcpServers).toEqual({ + test: { command: 'node', args: ['-e', 'console.log(1)'], env: { A: 'B' } }, + }); + }); + + it('uses HAPPIER_COPILOT_PATH when it points to an executable', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happier-copilot-path-')); + const fake = join(dir, 'copilot'); + try { + await writeFile(fake, '#!/bin/sh\necho hi\n', 'utf8'); + await chmod(fake, 0o755); + vi.stubEnv('HAPPIER_COPILOT_PATH', fake); + + const mod = await import('./backend'); + const opts = mod.buildCopilotAcpBackendOptions({ cwd: dir, env: {} }); + expect(opts.command).toBe(fake); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + diff --git a/apps/cli/src/backends/copilot/acp/backend.ts b/apps/cli/src/backends/copilot/acp/backend.ts new file mode 100644 index 000000000..4e755c5e7 --- /dev/null +++ b/apps/cli/src/backends/copilot/acp/backend.ts @@ -0,0 +1,55 @@ +/** + * Copilot ACP Backend - GitHub Copilot CLI agent via ACP. + * + * Copilot CLI must be installed and available in PATH. + * ACP mode: `copilot --acp` + */ + +import { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import { resolveCliPathOverride } from '@/agent/acp/resolveCliPathOverride'; +import type { AgentBackend, AgentFactoryOptions, McpServerConfig } from '@/agent/core'; +import { copilotTransport } from '@/backends/copilot/acp/transport'; +import type { PermissionMode } from '@/api/types'; +import { normalizePermissionModeToIntent } from '@/agent/runtime/permission/permissionModeCanonical'; + +export interface CopilotBackendOptions extends AgentFactoryOptions { + mcpServers?: Record; + permissionHandler?: AcpPermissionHandler; + permissionMode?: PermissionMode; +} + +/** + * Map Happier permission modes to Copilot CLI flags. + * + * Copilot CLI uses `--yolo` / `--allow-all-tools` rather than the + * `OPENCODE_PERMISSION` env var used by OpenCode-family agents. + */ +function buildCopilotPermissionArgs(permissionMode: PermissionMode | null | undefined): string[] { + const intent = normalizePermissionModeToIntent(permissionMode ?? 'default') ?? 'default'; + if (intent === 'yolo') { + return ['--yolo']; + } + return []; +} + +export function buildCopilotAcpBackendOptions(options: CopilotBackendOptions): AcpBackendOptions { + return { + agentName: 'copilot', + cwd: options.cwd, + command: resolveCliPathOverride({ agentId: 'copilot' }) ?? 'copilot', + args: ['--acp', ...buildCopilotPermissionArgs(options.permissionMode)], + env: { + // Suppress Copilot CLI debug noise by default; callers may override via options.env. + NODE_ENV: 'production', + DEBUG: '', + ...options.env, + }, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + transportHandler: copilotTransport, + }; +} + +export function createCopilotBackend(options: CopilotBackendOptions): AgentBackend { + return new AcpBackend(buildCopilotAcpBackendOptions(options)); +} diff --git a/apps/cli/src/backends/copilot/acp/runtime.ts b/apps/cli/src/backends/copilot/acp/runtime.ts new file mode 100644 index 000000000..496f42c28 --- /dev/null +++ b/apps/cli/src/backends/copilot/acp/runtime.ts @@ -0,0 +1,39 @@ +import type { McpServerConfig } from '@/agent'; +import type { AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import { createCatalogProviderAcpRuntime } from '@/agent/acp/runtime/createCatalogProviderAcpRuntime'; +import type { ApiSessionClient } from '@/api/session/sessionClient'; +import type { PermissionMode } from '@/api/types'; +import type { MessageBuffer } from '@/ui/ink/messageBuffer'; + +import { maybeUpdateCopilotSessionIdMetadata } from '@/backends/copilot/utils/copilotSessionIdMetadata'; + +export function createCopilotAcpRuntime(params: { + directory: string; + session: ApiSessionClient; + messageBuffer: MessageBuffer; + mcpServers: Record; + permissionHandler: AcpPermissionHandler; + onThinkingChange: (thinking: boolean) => void; + getPermissionMode?: () => PermissionMode | null | undefined; +}) { + const lastPublishedCopilotSessionId = { value: null as string | null }; + + return createCatalogProviderAcpRuntime({ + provider: 'copilot', + loggerLabel: 'CopilotACP', + directory: params.directory, + session: params.session, + messageBuffer: params.messageBuffer, + mcpServers: params.mcpServers, + permissionHandler: params.permissionHandler, + onThinkingChange: params.onThinkingChange, + getPermissionMode: params.getPermissionMode, + onSessionIdChange: (nextSessionId) => { + maybeUpdateCopilotSessionIdMetadata({ + getCopilotSessionId: () => nextSessionId, + updateHappySessionMetadata: (updater) => params.session.updateMetadata(updater), + lastPublished: lastPublishedCopilotSessionId, + }); + }, + }); +} diff --git a/apps/cli/src/backends/copilot/acp/transport.ts b/apps/cli/src/backends/copilot/acp/transport.ts new file mode 100644 index 000000000..2df60a407 --- /dev/null +++ b/apps/cli/src/backends/copilot/acp/transport.ts @@ -0,0 +1,185 @@ +/** + * Copilot Transport Handler + * + * Minimal TransportHandler for GitHub Copilot CLI's ACP mode. + * + * Copilot ACP is expected to speak JSON-RPC over ndJSON on stdout. + * This transport focuses on: + * - Conservative stdout filtering (JSON objects/arrays only) + * - Reasonable init/tool timeouts + * - Heuristics for mapping tool names to concrete tool names + * - Basic stderr classification (auth/model errors) + */ + +import type { + TransportHandler, + ToolPattern, + StderrContext, + StderrResult, + ToolNameContext, +} from '@/agent/transport/TransportHandler'; +import type { AgentMessage } from '@/agent/core'; +import { logger } from '@/ui/logger'; +import { filterJsonObjectOrArrayLine } from '@/agent/transport/utils/jsonStdoutFilter'; +import { + findToolNameFromId, + findToolNameFromInputFields, + type ToolPatternWithInputFields, +} from '@/agent/transport/utils/toolPatternInference'; + +export const COPILOT_TIMEOUTS = { + // Copilot may run auth checks or plugin setup on first ACP start. Be conservative. + init: 90_000, + toolCall: 120_000, + investigation: 300_000, + think: 30_000, + idle: 500, +} as const; + +const COPILOT_TOOL_PATTERNS: readonly ToolPatternWithInputFields[] = [ + { + name: 'change_title', + patterns: [ + 'change_title', + 'change-title', + 'happy__change_title', + 'mcp__happy__change_title', + 'happier__change_title', + 'mcp__happier__change_title', + ], + inputFields: ['title'], + }, + { + name: 'think', + patterns: ['think'], + inputFields: ['thought', 'thinking'], + }, + { name: 'read', patterns: ['read', 'read_file', 'read-file'], inputFields: ['path', 'filePath', 'uri'] }, + { name: 'write', patterns: ['write', 'write_file', 'write_to_file', 'write-file'], inputFields: ['path', 'filePath', 'content', 'text'] }, + { name: 'edit', patterns: ['edit', 'apply_diff', 'apply_patch', 'apply-diff', 'apply-patch'], inputFields: ['changes', 'old_string', 'new_string', 'edits'] }, + { name: 'bash', patterns: ['bash', 'shell', 'exec', 'exec_command', 'execute_command'], inputFields: ['command', 'cmd'] }, + { name: 'glob', patterns: ['glob', 'glob_files'], inputFields: ['pattern', 'glob'] }, + { name: 'grep', patterns: ['grep', 'search_code', 'code_search'], inputFields: ['pattern', 'query', 'text'] }, + { name: 'ls', patterns: ['ls', 'list_files', 'ls_files'], inputFields: ['path', 'dir'] }, + { name: 'task', patterns: ['task', 'subtask'], inputFields: ['prompt'] }, + // MCP wrappers (prefer resolving via tool_name hint) + { name: 'mcp', patterns: ['use_mcp_tool', 'access_mcp_resource'], inputFields: ['server', 'tool', 'name', 'uri'] }, +] as const; + +export class CopilotTransport implements TransportHandler { + readonly agentName = 'copilot'; + + getInitTimeout(): number { + return COPILOT_TIMEOUTS.init; + } + + filterStdoutLine(line: string): string | null { + return filterJsonObjectOrArrayLine(line); + } + + handleStderr(text: string, _context: StderrContext): StderrResult { + const trimmed = text.trim(); + if (!trimmed) return { message: null }; + + if ( + trimmed.toLowerCase().includes('authentication') || + trimmed.toLowerCase().includes('unauthorized') || + trimmed.toLowerCase().includes('not logged in') || + trimmed.includes('401') + ) { + const errorMessage: AgentMessage = { + type: 'status', + status: 'error', + detail: 'Authentication error. Run `copilot auth login` to authenticate with GitHub.', + }; + return { message: errorMessage }; + } + + if (trimmed.toLowerCase().includes('model not found') || trimmed.toLowerCase().includes('unknown model')) { + const errorMessage: AgentMessage = { + type: 'status', + status: 'error', + detail: 'Model not found. Check available models with `copilot models`.', + }; + return { message: errorMessage }; + } + + if ( + trimmed.toLowerCase().includes('subscription') || + trimmed.toLowerCase().includes('copilot is not enabled') + ) { + const errorMessage: AgentMessage = { + type: 'status', + status: 'error', + detail: 'GitHub Copilot subscription required. Check your Copilot access at https://github.com/settings/copilot.', + }; + return { message: errorMessage }; + } + + return { message: null }; + } + + getToolPatterns(): ToolPattern[] { + return [...COPILOT_TOOL_PATTERNS]; + } + + determineToolName( + toolName: string, + toolCallId: string, + input: Record, + _context: ToolNameContext, + ): string { + const directToolName = findToolNameFromId(toolName, COPILOT_TOOL_PATTERNS, { preferLongestMatch: true }); + if (directToolName) return directToolName; + + if (toolName !== 'other' && toolName !== 'Unknown tool') return toolName; + + const idToolName = findToolNameFromId(toolCallId, COPILOT_TOOL_PATTERNS, { preferLongestMatch: true }); + if (idToolName && idToolName !== 'mcp') return idToolName; + + const inputToolName = findToolNameFromInputFields(input, COPILOT_TOOL_PATTERNS); + if (inputToolName && inputToolName !== 'mcp') return inputToolName; + + const toolNameHintRaw = (() => { + const candidates = [input.tool_name, input.toolName, input.name]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) return candidate.trim(); + } + return null; + })(); + if (toolNameHintRaw) { + const hintToolName = findToolNameFromId(toolNameHintRaw, COPILOT_TOOL_PATTERNS, { preferLongestMatch: true }); + if (hintToolName) return hintToolName; + } + + if (idToolName) return idToolName; + if (inputToolName) return inputToolName; + + const inputKeys = input && typeof input === 'object' ? Object.keys(input) : []; + logger.debug( + `[CopilotTransport] Unknown tool pattern - toolCallId: "${toolCallId}", toolName: "${toolName}", inputKeys: [${inputKeys.join(', ')}].`, + ); + return toolName; + } + + extractToolNameFromId(toolCallId: string): string | null { + return findToolNameFromId(toolCallId, COPILOT_TOOL_PATTERNS, { preferLongestMatch: true }); + } + + isInvestigationTool(toolCallId: string, toolKind?: string): boolean { + const lowerId = toolCallId.toLowerCase(); + return lowerId.includes('task') || (typeof toolKind === 'string' && toolKind.includes('task')); + } + + getToolCallTimeout(toolCallId: string, toolKind?: string): number { + if (this.isInvestigationTool(toolCallId, toolKind)) return COPILOT_TIMEOUTS.investigation; + if (toolKind === 'think') return COPILOT_TIMEOUTS.think; + return COPILOT_TIMEOUTS.toolCall; + } + + getIdleTimeout(): number { + return COPILOT_TIMEOUTS.idle; + } +} + +export const copilotTransport = new CopilotTransport(); diff --git a/apps/cli/src/backends/copilot/cli/capability.ts b/apps/cli/src/backends/copilot/cli/capability.ts new file mode 100644 index 000000000..6a69a8bf8 --- /dev/null +++ b/apps/cli/src/backends/copilot/cli/capability.ts @@ -0,0 +1,9 @@ +import { createAcpCliCapability } from '@/capabilities/probes/createAcpCliCapability'; +import { copilotTransport } from '@/backends/copilot/acp/transport'; + +export const cliCapability = createAcpCliCapability({ + agentId: 'copilot', + title: 'Copilot CLI', + acpArgs: ['--acp'], + transport: copilotTransport, +}); diff --git a/apps/cli/src/backends/copilot/cli/checklists.ts b/apps/cli/src/backends/copilot/cli/checklists.ts new file mode 100644 index 000000000..6e5165679 --- /dev/null +++ b/apps/cli/src/backends/copilot/cli/checklists.ts @@ -0,0 +1,5 @@ +import type { AgentChecklistContributions } from '@/backends/types'; + +export const checklists = { + 'resume.copilot': [{ id: 'cli.copilot', params: { includeAcpCapabilities: true, includeLoginStatus: true } }], +} satisfies AgentChecklistContributions; diff --git a/apps/cli/src/backends/copilot/cli/command.ts b/apps/cli/src/backends/copilot/cli/command.ts new file mode 100644 index 000000000..5228faa1b --- /dev/null +++ b/apps/cli/src/backends/copilot/cli/command.ts @@ -0,0 +1,12 @@ +import { runCopilot } from '@/backends/copilot/runCopilot'; +import { runBackendSessionCliCommand } from '@/cli/runBackendSessionCliCommand'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleCopilotCliCommand(context: CommandContext): Promise { + await runBackendSessionCliCommand({ + context, + loadRun: async () => runCopilot, + agentIdForAccountSettings: 'copilot', + }); +} diff --git a/apps/cli/src/backends/copilot/cli/detect.ts b/apps/cli/src/backends/copilot/cli/detect.ts new file mode 100644 index 000000000..3fcd6de04 --- /dev/null +++ b/apps/cli/src/backends/copilot/cli/detect.ts @@ -0,0 +1,6 @@ +import type { CliDetectSpec } from '@/backends/types'; + +export const cliDetect = { + versionArgsToTry: [['--version'], ['version']], + loginStatusArgs: null, +} satisfies CliDetectSpec; diff --git a/apps/cli/src/backends/copilot/executionRuns/executionRunBackendFactory.ts b/apps/cli/src/backends/copilot/executionRuns/executionRunBackendFactory.ts new file mode 100644 index 000000000..955f0ec43 --- /dev/null +++ b/apps/cli/src/backends/copilot/executionRuns/executionRunBackendFactory.ts @@ -0,0 +1,12 @@ +import { createCopilotBackend } from '@/backends/copilot/acp/backend'; +import type { ExecutionRunBackendFactory } from '@/backends/executionRuns/types'; +import { permissionModeForExecutionRunPolicy } from '@/backends/executionRuns/permissionModeForExecutionRunPolicy'; + +export const executionRunBackendFactory: ExecutionRunBackendFactory = (opts) => { + return createCopilotBackend({ + cwd: opts.cwd, + env: opts.isolation?.env, + permissionHandler: opts.permissionHandler, + permissionMode: permissionModeForExecutionRunPolicy(opts.permissionMode), + }); +}; diff --git a/apps/cli/src/backends/copilot/index.ts b/apps/cli/src/backends/copilot/index.ts new file mode 100644 index 000000000..e4dd39e48 --- /dev/null +++ b/apps/cli/src/backends/copilot/index.ts @@ -0,0 +1,18 @@ +import { AGENTS_CORE } from '@happier-dev/agents'; + +import { checklists } from './cli/checklists'; +import type { AgentCatalogEntry } from '../types'; + +export const agent = { + id: AGENTS_CORE.copilot.id, + cliSubcommand: AGENTS_CORE.copilot.cliSubcommand, + getCliCommandHandler: async () => (await import('@/backends/copilot/cli/command')).handleCopilotCliCommand, + getCliCapabilityOverride: async () => (await import('@/backends/copilot/cli/capability')).cliCapability, + getCliDetect: async () => (await import('@/backends/copilot/cli/detect')).cliDetect, + vendorResumeSupport: AGENTS_CORE.copilot.resume.vendorResume, + getAcpBackendFactory: async () => { + const { createCopilotBackend } = await import('@/backends/copilot/acp/backend'); + return (opts) => ({ backend: createCopilotBackend(opts as any) }); + }, + checklists, +} satisfies AgentCatalogEntry; diff --git a/apps/cli/src/backends/copilot/runCopilot.ts b/apps/cli/src/backends/copilot/runCopilot.ts new file mode 100644 index 000000000..971336f39 --- /dev/null +++ b/apps/cli/src/backends/copilot/runCopilot.ts @@ -0,0 +1,47 @@ +/** + * Copilot CLI Entry Point + * + * Runs the GitHub Copilot agent through Happier CLI using ACP. + */ + +import type { PermissionMode } from '@/api/types'; +import { logger } from '@/ui/logger'; +import type { Credentials } from '@/persistence'; +import { initialMachineMetadata } from '@/daemon/startDaemon'; +import { runStandardAcpProvider, type StandardAcpProviderRunOptions } from '@/agent/runtime/runStandardAcpProvider'; + +import { CopilotTerminalDisplay } from '@/backends/copilot/ui/CopilotTerminalDisplay'; +import { createCopilotAcpRuntime } from '@/backends/copilot/acp/runtime'; + +export async function runCopilot(opts: StandardAcpProviderRunOptions & { + credentials: Credentials; + permissionMode?: PermissionMode; +}): Promise { + await runStandardAcpProvider(opts, { + flavor: 'copilot', + backendDisplayName: 'Copilot', + uiLogPrefix: '[Copilot]', + providerName: 'Copilot', + waitingForCommandLabel: 'Copilot', + agentMessageType: 'copilot', + machineMetadata: initialMachineMetadata, + terminalDisplay: CopilotTerminalDisplay, + resolveRuntimeDirectory: ({ session, metadata }) => session.getMetadataSnapshot()?.path ?? metadata.path, + createRuntime: ({ directory, session, messageBuffer, mcpServers, permissionHandler, setThinking, getPermissionMode }) => createCopilotAcpRuntime({ + directory, + session, + messageBuffer, + mcpServers, + permissionHandler, + onThinkingChange: setThinking, + getPermissionMode, + }), + onAttachMetadataSnapshotMissing: (error) => { + logger.debug( + '[copilot] Failed to fetch session metadata snapshot before attach startup update; continuing without metadata write (non-fatal)', + error ?? undefined, + ); + }, + formatPromptErrorMessage: (error) => `Error: ${error instanceof Error ? error.message : String(error)}`, + }); +} diff --git a/apps/cli/src/backends/copilot/ui/CopilotTerminalDisplay.tsx b/apps/cli/src/backends/copilot/ui/CopilotTerminalDisplay.tsx new file mode 100644 index 000000000..5be824b09 --- /dev/null +++ b/apps/cli/src/backends/copilot/ui/CopilotTerminalDisplay.tsx @@ -0,0 +1,31 @@ +/** + * CopilotTerminalDisplay + * + * Read-only terminal UI for Copilot sessions started by Happy. + * This UI intentionally does not accept prompts from stdin; it displays logs and exit controls only. + */ + +import React from 'react'; + +import { AgentLogShell } from '@/ui/ink/AgentLogShell'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { buildReadOnlyFooterLines } from '@/ui/ink/readOnlyFooterLines'; + +export interface CopilotTerminalDisplayProps { + messageBuffer: MessageBuffer; + logPath?: string; + onExit?: () => void | Promise; +} + +export const CopilotTerminalDisplay: React.FC = ({ messageBuffer, logPath, onExit }) => { + return ( + + ); +}; diff --git a/apps/cli/src/backends/copilot/utils/copilotSessionIdMetadata.ts b/apps/cli/src/backends/copilot/utils/copilotSessionIdMetadata.ts new file mode 100644 index 000000000..6a5bad269 --- /dev/null +++ b/apps/cli/src/backends/copilot/utils/copilotSessionIdMetadata.ts @@ -0,0 +1,31 @@ +import type { Metadata } from '@/api/types'; + +export function maybeUpdateCopilotSessionIdMetadata(params: { + getCopilotSessionId: () => string | null; + updateHappySessionMetadata: (updater: (metadata: Metadata) => Metadata) => Promise | void; + lastPublished: { value: string | null }; +}): void { + const raw = params.getCopilotSessionId(); + const next = typeof raw === 'string' ? raw.trim() : ''; + if (!next) return; + + if (params.lastPublished.value === next) return; + + const prev = params.lastPublished.value; + params.lastPublished.value = next; + try { + const res = params.updateHappySessionMetadata((metadata) => ({ + ...metadata, + copilotSessionId: next, + })); + void Promise.resolve(res).catch(() => { + if (params.lastPublished.value === next) { + params.lastPublished.value = prev; + } + }); + } catch { + if (params.lastPublished.value === next) { + params.lastPublished.value = prev; + } + } +} diff --git a/apps/cli/src/backends/copilot/utils/permissionHandler.ts b/apps/cli/src/backends/copilot/utils/permissionHandler.ts new file mode 100644 index 000000000..50fa846ff --- /dev/null +++ b/apps/cli/src/backends/copilot/utils/permissionHandler.ts @@ -0,0 +1,38 @@ +/** + * Copilot Permission Handler + * + * Mode-aware (same semantics as other ACP backends). Treat guard-like permission kinds as + * write-like so they surface prompts in safe-yolo/default modes. + */ + +import type { ApiSessionClient } from '@/api/session/sessionClient'; +import { + CodexLikePermissionHandler, + type PermissionResult, + type PendingRequest, + isDefaultWriteLikeToolName, +} from '@/agent/permissions/CodexLikePermissionHandler'; + +export type { PermissionResult, PendingRequest }; + +const COPILOT_WRITE_LIKE_PERMISSION_KINDS = new Set([ + 'external_directory', + 'doom_loop', +]); + +export class CopilotPermissionHandler extends CodexLikePermissionHandler { + constructor( + session: ApiSessionClient, + opts?: { onAbortRequested?: (() => void | Promise) | null }, + ) { + super({ + session, + logPrefix: '[Copilot]', + onAbortRequested: typeof opts?.onAbortRequested === 'function' ? opts.onAbortRequested : null, + isWriteLikeToolName: (toolName) => { + const lower = toolName.toLowerCase(); + return isDefaultWriteLikeToolName(toolName) || COPILOT_WRITE_LIKE_PERMISSION_KINDS.has(lower); + }, + }); + } +} diff --git a/apps/cli/src/backends/executionRuns/executionRunBackendRegistry.ts b/apps/cli/src/backends/executionRuns/executionRunBackendRegistry.ts index 4d34a1970..e2ff3ac56 100644 --- a/apps/cli/src/backends/executionRuns/executionRunBackendRegistry.ts +++ b/apps/cli/src/backends/executionRuns/executionRunBackendRegistry.ts @@ -9,6 +9,7 @@ import { executionRunBackendFactory as auggie } from '@/backends/auggie/executio import { executionRunBackendFactory as qwen } from '@/backends/qwen/executionRuns/executionRunBackendFactory'; import { executionRunBackendFactory as kimi } from '@/backends/kimi/executionRuns/executionRunBackendFactory'; import { executionRunBackendFactory as kilo } from '@/backends/kilo/executionRuns/executionRunBackendFactory'; +import { executionRunBackendFactory as copilot } from '@/backends/copilot/executionRuns/executionRunBackendFactory'; import { listNativeReviewEngineIds, resolveNativeReviewExecutionRunBackendFactory } from '@/agent/reviews/engines/nativeReviewEngines'; @@ -26,6 +27,7 @@ const REGISTRY: Record = { qwen: { factory: qwen }, kimi: { factory: kimi }, kilo: { factory: kilo }, + copilot: { factory: copilot }, }; for (const engineId of listNativeReviewEngineIds()) { diff --git a/apps/cli/src/backends/executionRuns/permissionModeForExecutionRunPolicy.test.ts b/apps/cli/src/backends/executionRuns/permissionModeForExecutionRunPolicy.test.ts new file mode 100644 index 000000000..d9176b158 --- /dev/null +++ b/apps/cli/src/backends/executionRuns/permissionModeForExecutionRunPolicy.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { permissionModeForExecutionRunPolicy } from './permissionModeForExecutionRunPolicy'; + +describe('permissionModeForExecutionRunPolicy', () => { + it('maps execution-run safe policies to PermissionMode', () => { + expect(permissionModeForExecutionRunPolicy('read_only')).toBe('read-only'); + expect(permissionModeForExecutionRunPolicy('no_tools')).toBe('read-only'); + }); + + it('passes through canonical PermissionMode tokens', () => { + expect(permissionModeForExecutionRunPolicy('read-only')).toBe('read-only'); + expect(permissionModeForExecutionRunPolicy('safe-yolo')).toBe('safe-yolo'); + expect(permissionModeForExecutionRunPolicy('yolo')).toBe('yolo'); + expect(permissionModeForExecutionRunPolicy('default')).toBe('default'); + }); + + it('falls back to default for unknown values', () => { + expect(permissionModeForExecutionRunPolicy('')).toBe('default'); + expect(permissionModeForExecutionRunPolicy('not-a-mode')).toBe('default'); + }); +}); + diff --git a/apps/cli/src/backends/executionRuns/permissionModeForExecutionRunPolicy.ts b/apps/cli/src/backends/executionRuns/permissionModeForExecutionRunPolicy.ts new file mode 100644 index 000000000..6865cb008 --- /dev/null +++ b/apps/cli/src/backends/executionRuns/permissionModeForExecutionRunPolicy.ts @@ -0,0 +1,20 @@ +import type { PermissionMode } from '@/api/types'; +import { isPermissionMode } from '@/api/types'; + +/** + * Execution-run permission policies are intentionally restrictive and use + * historical tokens like `read_only` / `no_tools`. + * + * Map them onto the canonical PermissionMode surface used by ACP backends. + */ +export function permissionModeForExecutionRunPolicy(raw: string): PermissionMode { + const mode = String(raw ?? '').trim(); + if (!mode) return 'default'; + + if (mode === 'read_only' || mode === 'no_tools') { + return 'read-only'; + } + + return isPermissionMode(mode) ? mode : 'default'; +} + diff --git a/apps/cli/src/backends/kilo/cli/capability.loadSession.e2e.test.ts b/apps/cli/src/backends/kilo/cli/capability.loadSession.e2e.test.ts index 66c47e37a..976b9815e 100644 --- a/apps/cli/src/backends/kilo/cli/capability.loadSession.e2e.test.ts +++ b/apps/cli/src/backends/kilo/cli/capability.loadSession.e2e.test.ts @@ -90,6 +90,7 @@ describe('cli.kilo capability (ACP)', () => { kimi: makeUnavailableCliEntry(), kilo: { available: true, resolvedPath }, pi: makeUnavailableCliEntry(), + copilot: makeUnavailableCliEntry(), }, tmux: { available: false }, }, diff --git a/apps/cli/src/backends/kilo/executionRuns/executionRunBackendFactory.ts b/apps/cli/src/backends/kilo/executionRuns/executionRunBackendFactory.ts index c4edb1c67..dcafdb6b6 100644 --- a/apps/cli/src/backends/kilo/executionRuns/executionRunBackendFactory.ts +++ b/apps/cli/src/backends/kilo/executionRuns/executionRunBackendFactory.ts @@ -1,11 +1,12 @@ import { createKiloBackend } from '@/backends/kilo/acp/backend'; import type { ExecutionRunBackendFactory } from '@/backends/executionRuns/types'; +import { permissionModeForExecutionRunPolicy } from '@/backends/executionRuns/permissionModeForExecutionRunPolicy'; export const executionRunBackendFactory: ExecutionRunBackendFactory = (opts) => { return createKiloBackend({ cwd: opts.cwd, env: opts.isolation?.env, permissionHandler: opts.permissionHandler, - permissionMode: opts.permissionMode as any, + permissionMode: permissionModeForExecutionRunPolicy(opts.permissionMode), }); }; diff --git a/apps/cli/src/backends/opencode/executionRuns/executionRunBackendFactory.ts b/apps/cli/src/backends/opencode/executionRuns/executionRunBackendFactory.ts index 09f7670bd..34dd3e895 100644 --- a/apps/cli/src/backends/opencode/executionRuns/executionRunBackendFactory.ts +++ b/apps/cli/src/backends/opencode/executionRuns/executionRunBackendFactory.ts @@ -1,11 +1,12 @@ import { createOpenCodeBackend } from '@/backends/opencode/acp/backend'; import type { ExecutionRunBackendFactory } from '@/backends/executionRuns/types'; +import { permissionModeForExecutionRunPolicy } from '@/backends/executionRuns/permissionModeForExecutionRunPolicy'; export const executionRunBackendFactory: ExecutionRunBackendFactory = (opts) => { return createOpenCodeBackend({ cwd: opts.cwd, env: opts.isolation?.env, permissionHandler: opts.permissionHandler, - permissionMode: opts.permissionMode as any, + permissionMode: permissionModeForExecutionRunPolicy(opts.permissionMode), }); }; diff --git a/apps/cli/src/backends/pi/rpc/PiRpcBackend.ts b/apps/cli/src/backends/pi/rpc/PiRpcBackend.ts index 04c80dc79..f4316c84c 100644 --- a/apps/cli/src/backends/pi/rpc/PiRpcBackend.ts +++ b/apps/cli/src/backends/pi/rpc/PiRpcBackend.ts @@ -466,6 +466,7 @@ export class PiRpcBackend implements AgentBackend { ...this.options.env, }, stdio: 'pipe', + windowsHide: true, }); if (!child.stdin || !child.stdout || !child.stderr) { diff --git a/apps/cli/src/capabilities/checklists.ts b/apps/cli/src/capabilities/checklists.ts index 776e41fe8..7410faa4d 100644 --- a/apps/cli/src/capabilities/checklists.ts +++ b/apps/cli/src/capabilities/checklists.ts @@ -3,6 +3,7 @@ import { AGENTS } from '@/backends/catalog'; import { CATALOG_AGENT_IDS } from '@/backends/types'; import type { CatalogAgentId } from '@/backends/types'; import { AGENTS_CORE } from '@happier-dev/agents'; +import { CODEX_ACP_DEP_ID, CODEX_MCP_RESUME_DEP_ID } from '@happier-dev/protocol/installables'; import { CHECKLIST_IDS, resumeChecklistId, type ChecklistId } from './checklistIds'; import type { CapabilityDetectRequest } from './types'; @@ -58,8 +59,8 @@ const baseChecklists = { ...cliAgentRequests, { id: 'tool.tmux' }, { id: 'tool.executionRuns' }, - { id: 'dep.codex-mcp-resume' }, - { id: 'dep.codex-acp' }, + { id: CODEX_MCP_RESUME_DEP_ID }, + { id: CODEX_ACP_DEP_ID }, ], ...resumeChecklistEntries, } satisfies Record; diff --git a/apps/cli/src/capabilities/deps/codexAcp.ts b/apps/cli/src/capabilities/deps/codexAcp.ts index fd48bd2ce..6b9f7bbbc 100644 --- a/apps/cli/src/capabilities/deps/codexAcp.ts +++ b/apps/cli/src/capabilities/deps/codexAcp.ts @@ -4,11 +4,11 @@ import { access, mkdir, readFile, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; import { promisify } from 'util'; import { configuration } from '@/configuration'; +import { CODEX_ACP_DIST_TAG } from '@happier-dev/protocol/installables'; const execFileAsync = promisify(execFile); export const CODEX_ACP_NPM_PACKAGE = '@zed-industries/codex-acp'; -export const CODEX_ACP_DIST_TAG = 'latest'; export const DEFAULT_CODEX_ACP_INSTALL_SPEC = `${CODEX_ACP_NPM_PACKAGE}@${CODEX_ACP_DIST_TAG}`; export const codexAcpInstallDir = () => join(configuration.happyHomeDir, 'tools', 'codex-acp'); diff --git a/apps/cli/src/capabilities/deps/codexMcpResume.legacyInstallRemoval.test.ts b/apps/cli/src/capabilities/deps/codexMcpResume.legacyInstallRemoval.test.ts new file mode 100644 index 000000000..cd73c9a41 --- /dev/null +++ b/apps/cli/src/capabilities/deps/codexMcpResume.legacyInstallRemoval.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const ORIGINAL_HOME = process.env.HAPPIER_HOME_DIR; +const tempDirs = new Set(); + +afterEach(async () => { + if (ORIGINAL_HOME === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = ORIGINAL_HOME; + vi.resetModules(); + for (const dir of tempDirs) { + await rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe.sequential('codexMcpResume dep status', () => { + it('does not treat the legacy codex-resume install dir as installed', async () => { + const home = await mkdtemp(join(tmpdir(), 'happier-codex-mcp-resume-')); + tempDirs.add(home); + process.env.HAPPIER_HOME_DIR = home; + + const binName = process.platform === 'win32' ? 'codex-mcp-resume.cmd' : 'codex-mcp-resume'; + const legacyBin = join(home, 'tools', 'codex-resume', 'node_modules', '.bin', binName); + await mkdir(join(home, 'tools', 'codex-resume', 'node_modules', '.bin'), { recursive: true }); + await writeFile(legacyBin, '#!/bin/sh\necho ok\n', 'utf8'); + if (process.platform !== 'win32') { + await chmod(legacyBin, 0o755); + } + + const { getCodexMcpResumeDepStatus } = await import('./codexMcpResume'); + const status = await getCodexMcpResumeDepStatus({ onlyIfInstalled: true }); + expect(status.installed).toBe(false); + }); +}); + diff --git a/apps/cli/src/capabilities/deps/codexMcpResume.ts b/apps/cli/src/capabilities/deps/codexMcpResume.ts index f5213bef8..d269eae8d 100644 --- a/apps/cli/src/capabilities/deps/codexMcpResume.ts +++ b/apps/cli/src/capabilities/deps/codexMcpResume.ts @@ -4,27 +4,21 @@ import { access, mkdir, readFile, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; import { promisify } from 'util'; import { configuration } from '@/configuration'; +import { CODEX_MCP_RESUME_DIST_TAG } from '@happier-dev/protocol/installables'; const execFileAsync = promisify(execFile); export const CODEX_MCP_RESUME_NPM_PACKAGE = '@leeroy/codex-mcp-resume'; -export const CODEX_MCP_RESUME_DIST_TAG = 'happy-codex-resume'; export const DEFAULT_CODEX_MCP_RESUME_INSTALL_SPEC = `${CODEX_MCP_RESUME_NPM_PACKAGE}@${CODEX_MCP_RESUME_DIST_TAG}`; export const codexResumeInstallDir = () => join(configuration.happyHomeDir, 'tools', 'codex-mcp-resume'); -export const codexResumeLegacyInstallDir = () => join(configuration.happyHomeDir, 'tools', 'codex-resume'); const codexResumeBinPath = () => { const binName = process.platform === 'win32' ? 'codex-mcp-resume.cmd' : 'codex-mcp-resume'; return join(codexResumeInstallDir(), 'node_modules', '.bin', binName); }; -const codexResumeLegacyBinPath = () => { - const binName = process.platform === 'win32' ? 'codex-mcp-resume.cmd' : 'codex-mcp-resume'; - return join(codexResumeLegacyInstallDir(), 'node_modules', '.bin', binName); -}; const codexResumeStatePath = () => join(codexResumeInstallDir(), 'install-state.json'); -const codexResumeLegacyStatePath = () => join(codexResumeLegacyInstallDir(), 'install-state.json'); async function readCodexResumeState(): Promise<{ lastInstallLogPath: string | null } | null> { try { @@ -37,19 +31,6 @@ async function readCodexResumeState(): Promise<{ lastInstallLogPath: string | nu } } -async function readCodexResumeStateWithFallback(): Promise<{ lastInstallLogPath: string | null } | null> { - const primary = await readCodexResumeState(); - if (primary) return primary; - try { - const raw = await readFile(codexResumeLegacyStatePath(), 'utf8'); - const parsed = JSON.parse(raw); - const lastInstallLogPath = typeof parsed?.lastInstallLogPath === 'string' ? parsed.lastInstallLogPath : null; - return { lastInstallLogPath }; - } catch { - return null; - } -} - async function writeCodexResumeState(next: { lastInstallLogPath: string | null }): Promise { await mkdir(codexResumeInstallDir(), { recursive: true }); await writeFile(codexResumeStatePath(), JSON.stringify(next, null, 2), 'utf8'); @@ -164,8 +145,7 @@ export async function getCodexMcpResumeDepStatus(opts?: { distTag?: string; }): Promise { const primaryBinPath = codexResumeBinPath(); - const legacyBinPath = codexResumeLegacyBinPath(); - const state = await readCodexResumeStateWithFallback(); + const state = await readCodexResumeState(); const accessMode = process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK; const installed = await (async () => { @@ -173,27 +153,12 @@ export async function getCodexMcpResumeDepStatus(opts?: { await access(primaryBinPath, accessMode); return true; } catch { - try { - await access(legacyBinPath, accessMode); - return true; - } catch { - return false; - } + return false; } })(); - const binPath = installed - ? await (async () => { - try { - await access(primaryBinPath, accessMode); - return primaryBinPath; - } catch { - return legacyBinPath; - } - })() - : null; - - const installDir = binPath?.startsWith(codexResumeLegacyInstallDir()) ? codexResumeLegacyInstallDir() : codexResumeInstallDir(); + const binPath = installed ? primaryBinPath : null; + const installDir = codexResumeInstallDir(); const installedVersion = await readInstalledNpmPackageVersion({ installDir, packageName: CODEX_MCP_RESUME_NPM_PACKAGE }); const includeRegistry = Boolean(opts?.includeRegistry); const onlyIfInstalled = Boolean(opts?.onlyIfInstalled); diff --git a/apps/cli/src/capabilities/probes/agentModelsProbe.ts b/apps/cli/src/capabilities/probes/agentModelsProbe.ts index a21073dc9..dff112991 100644 --- a/apps/cli/src/capabilities/probes/agentModelsProbe.ts +++ b/apps/cli/src/capabilities/probes/agentModelsProbe.ts @@ -94,6 +94,7 @@ async function probeModelsFromCliModelsCommand(params: { cwd: params.cwd, env: { ...process.env, CI: '1' }, stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, }); const timer = setTimeout(() => { diff --git a/apps/cli/src/capabilities/registry/depCodexAcp.ts b/apps/cli/src/capabilities/registry/depCodexAcp.ts index 7b90beb9a..9e8d1af88 100644 --- a/apps/cli/src/capabilities/registry/depCodexAcp.ts +++ b/apps/cli/src/capabilities/registry/depCodexAcp.ts @@ -1,10 +1,11 @@ import type { Capability } from '../service'; import { CapabilityError } from '../errors'; import { getCodexAcpDepStatus, installCodexAcp } from '../deps/codexAcp'; +import { CODEX_ACP_DEP_ID } from '@happier-dev/protocol/installables'; export const codexAcpDepCapability: Capability = { descriptor: { - id: 'dep.codex-acp', + id: CODEX_ACP_DEP_ID, kind: 'dep', title: 'Codex ACP', methods: { diff --git a/apps/cli/src/capabilities/registry/depCodexMcpResume.ts b/apps/cli/src/capabilities/registry/depCodexMcpResume.ts index fa63294aa..a4937eff0 100644 --- a/apps/cli/src/capabilities/registry/depCodexMcpResume.ts +++ b/apps/cli/src/capabilities/registry/depCodexMcpResume.ts @@ -1,10 +1,11 @@ import type { Capability } from '../service'; import { CapabilityError } from '../errors'; import { getCodexMcpResumeDepStatus, installCodexMcpResume } from '../deps/codexMcpResume'; +import { CODEX_MCP_RESUME_DEP_ID } from '@happier-dev/protocol/installables'; export const codexMcpResumeDepCapability: Capability = { descriptor: { - id: 'dep.codex-mcp-resume', + id: CODEX_MCP_RESUME_DEP_ID, kind: 'dep', title: 'Codex MCP resume', methods: { diff --git a/apps/cli/src/cli/commands/resume.integration.test.ts b/apps/cli/src/cli/commands/resume.integration.test.ts index bb42a95ec..a348f7326 100644 --- a/apps/cli/src/cli/commands/resume.integration.test.ts +++ b/apps/cli/src/cli/commands/resume.integration.test.ts @@ -61,9 +61,19 @@ describe('happier resume command (integration)', () => { JSON.stringify({ session: { id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, metadata: metadataCiphertext, metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + share: null, }, }), ); diff --git a/apps/cli/src/cli/commands/resume.test.ts b/apps/cli/src/cli/commands/resume.test.ts index 9ff946403..3d0113450 100644 --- a/apps/cli/src/cli/commands/resume.test.ts +++ b/apps/cli/src/cli/commands/resume.test.ts @@ -11,6 +11,7 @@ import { reloadConfiguration } from '@/configuration'; import type { Credentials } from '@/persistence'; import { encodeBase64, encrypt } from '@/api/encryption'; import { readSessionAttachFromEnv } from '@/agent/runtime/sessionAttach'; +import { makeSessionFixtureRow } from '@/sessionControl/testFixtures'; import { handleResumeCommand } from './resume'; @@ -61,18 +62,19 @@ describe('happier resume', () => { }); const rawSession = { - id: 'sid_1', - dataEncryptionKey: encodeBase64(envelope), - metadataVersion: 1, - agentStateVersion: 1, - metadata: encodeBase64( - encrypt(sessionEncryptionKey, 'dataKey', { - path: directory, - host: 'test', - flavor: 'codex', - }), - ), - agentState: null, + ...makeSessionFixtureRow({ + id: 'sid_1', + dataEncryptionKey: encodeBase64(envelope), + metadata: encodeBase64( + encrypt(sessionEncryptionKey, 'dataKey', { + path: directory, + host: 'test', + flavor: 'codex', + }), + ), + active: true, + activeAt: 1, + }), }; const dispatched: { args: string[] }[] = []; diff --git a/apps/cli/src/cli/commands/self.ts b/apps/cli/src/cli/commands/self.ts index 85da685a9..145eef80f 100644 --- a/apps/cli/src/cli/commands/self.ts +++ b/apps/cli/src/cli/commands/self.ts @@ -7,12 +7,13 @@ import { configuration } from '@/configuration'; import type { CommandContext } from '@/cli/commandRegistry'; import { compareVersions, - installRuntimeFromNpm, readNpmDistTagVersion, readUpdateCache, resolveNpmPackageNameOverride, writeUpdateCache, } from '@happier-dev/cli-common/update'; +import { fetchGitHubReleaseByTag } from '@happier-dev/release-runtime/github'; +import { resolveCliBinaryAssetBundleFromReleaseAssets, updateCliBinaryFromGitHubTag } from '@/cli/runtime/update/binarySelfUpdate'; type SelfChannel = 'stable' | 'preview'; @@ -32,6 +33,8 @@ function usage(): string { `${chalk.bold('Environment:')}`, ` HAPPIER_CLI_UPDATE_CHECK=0 Disable update notice + background check`, ` HAPPIER_CLI_UPDATE_PACKAGE_NAME=@scope/pkg Override the npm package name checked/installed`, + ` HAPPIER_GITHUB_REPO=happier-dev/happier Override GitHub repo for binary updates`, + ` HAPPIER_GITHUB_TOKEN=... GitHub token for release API (optional)`, '', ].join('\n'); } @@ -104,6 +107,32 @@ export function detectInstallSource(path: string): 'npm' | 'binary' { return 'binary'; } +function resolveBinaryUpdateRepo(env: NodeJS.ProcessEnv): string { + const raw = String(env.HAPPIER_GITHUB_REPO ?? '').trim(); + return raw || 'happier-dev/happier'; +} + +function resolveBinaryUpdateToken(env: NodeJS.ProcessEnv): string { + return String(env.HAPPIER_GITHUB_TOKEN ?? env.GITHUB_TOKEN ?? '').trim(); +} + +function resolveBinaryUpdatePlatform(env: NodeJS.ProcessEnv): Readonly<{ os: string; arch: string }> { + const forcedOs = String(env.HAPPIER_SELF_UPDATE_OS ?? '').trim(); + const forcedArch = String(env.HAPPIER_SELF_UPDATE_ARCH ?? '').trim(); + if (forcedOs && forcedArch) return { os: forcedOs, arch: forcedArch }; + + const os = process.platform === 'linux' ? 'linux' : process.platform === 'darwin' ? 'darwin' : 'unsupported'; + const arch = process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : 'unsupported'; + if (os === 'unsupported' || arch === 'unsupported') { + throw new Error(`Unsupported platform for binary updates: ${process.platform}/${process.arch}`); + } + return { os, arch }; +} + +function resolveBinaryUpdateTag(channel: SelfChannel): string { + return channel === 'preview' ? 'cli-preview' : 'cli-stable'; +} + function npmUpgradeCommand(params: Readonly<{ packageName: string; channel: SelfChannel; to: string }>): string { const pkg = String(params.packageName ?? '').trim(); const to = String(params.to ?? '').trim(); @@ -129,6 +158,45 @@ function resolveUpdatePackageName(): string { async function cmdCheck(argv: string[]): Promise { const channel = parseSelfChannel(argv); const quiet = argv.includes('--quiet'); + const installSource = detectInstallSource(process.argv[1] ?? ''); + + if (installSource === 'binary') { + const { os, arch } = resolveBinaryUpdatePlatform(process.env); + const githubRepo = resolveBinaryUpdateRepo(process.env); + const githubToken = resolveBinaryUpdateToken(process.env); + const tag = resolveBinaryUpdateTag(channel); + + const release = await fetchGitHubReleaseByTag({ githubRepo, tag, githubToken, userAgent: 'happier-cli' }); + const assets = typeof release === 'object' && release != null && 'assets' in release ? (release as any).assets : null; + const bundle = resolveCliBinaryAssetBundleFromReleaseAssets({ assets, os, arch, preferVersion: null }); + + const latest = bundle.version; + const invokerVersion = configuration.currentCliVersion; + const current = invokerVersion || null; + const updateAvailable = Boolean(current && latest && compareVersions(latest, current) > 0); + + const existing = readUpdateCache(cachePath()); + const checkedAt = Date.now(); + writeUpdateCache(cachePath(), { + checkedAt, + latest, + current, + runtimeVersion: null, + invokerVersion, + updateAvailable, + notifiedAt: existing?.notifiedAt ?? null, + }); + + if (quiet) return; + + if (updateAvailable) { + console.log(chalk.yellow(`Update available: ${current ?? 'current'} → ${latest}`)); + console.log(chalk.gray('Run:'), chalk.cyan('happier self update')); + return; + } + console.log(chalk.green('Up to date.')); + return; + } const distTag = channel === 'preview' ? 'next' : 'latest'; const pkgName = resolveUpdatePackageName(); @@ -175,24 +243,43 @@ async function cmdUpdate(argv: string[]): Promise { return eq ? eq.slice('--to='.length) : ''; })(); - const pkgName = resolveUpdatePackageName(); const installSource = detectInstallSource(process.argv[1] ?? ''); if (installSource === 'npm') { + const pkgName = resolveUpdatePackageName(); const upgrade = npmUpgradeCommand({ packageName: pkgName, channel, to: toArg }); console.log(chalk.yellow('Detected npm-based install; in-place runtime update is disabled.')); console.log(chalk.gray('Run instead:'), chalk.cyan(upgrade)); return; } - const spec = computeSelfUpdateSpec({ packageName: pkgName, channel, to: toArg }); - const res = installRuntimeFromNpm({ runtimeDir: runtimeDir(), spec, cwd: process.cwd(), env: process.env }); - if (!res.ok) { - console.error(chalk.red('Error:'), res.errorMessage); - process.exit(1); - } + + const effective = (() => { + const raw = String(toArg ?? '').trim(); + if (raw === 'latest') return { channel: 'stable' as const, preferVersion: null }; + if (raw === 'next') return { channel: 'preview' as const, preferVersion: null }; + const v = raw.startsWith('v') ? raw.slice(1) : raw; + return { channel, preferVersion: v || null }; + })(); + + const { os, arch } = resolveBinaryUpdatePlatform(process.env); + const githubRepo = resolveBinaryUpdateRepo(process.env); + const githubToken = resolveBinaryUpdateToken(process.env); + const tag = resolveBinaryUpdateTag(effective.channel); + const minisignPubkeyFile = String(process.env.HAPPIER_MINISIGN_PUBKEY ?? '').trim() || undefined; + + const result = await updateCliBinaryFromGitHubTag({ + githubRepo, + tag, + githubToken, + os, + arch, + execPath: process.execPath, + preferVersion: effective.preferVersion, + minisignPubkeyFile, + }); // Refresh cache best-effort. - await cmdCheck(['check', '--quiet', ...(channel === 'preview' ? ['--preview'] : [])]); - console.log(chalk.green('✓ Updated runtime.')); + await cmdCheck(['check', '--quiet', ...(effective.channel === 'preview' ? ['--preview'] : [])]); + console.log(chalk.green(`✓ Updated binary to ${result.updatedTo}`)); } export async function handleSelfCliCommand(context: CommandContext): Promise { diff --git a/apps/cli/src/cli/commands/session/archive.integration.test.ts b/apps/cli/src/cli/commands/session/archive.integration.test.ts new file mode 100644 index 000000000..c4879fb5d --- /dev/null +++ b/apps/cli/src/cli/commands/session/archive.integration.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +describe('happier session archive/unarchive (integration)', () => { + const originalServerUrl = process.env.HAPPIER_SERVER_URL; + const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; + const originalHomeDir = process.env.HAPPIER_HOME_DIR; + let server: Server | null = null; + let happyHomeDir = ''; + + const sessionId = 'sess_integration_archive_123'; + + beforeEach(async () => { + happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-archive-')); + + server = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: 'metadata_ciphertext', + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + share: null, + archivedAt: null, + }, + }), + ); + return; + } + + if (req.method === 'POST' && url.pathname === `/v2/sessions/${sessionId}/archive`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ success: true, archivedAt: 123 })); + return; + } + + if (req.method === 'POST' && url.pathname === `/v2/sessions/${sessionId}/unarchive`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ success: true, archivedAt: null })); + return; + } + + res.statusCode = 404; + res.end(); + }); + + await new Promise((resolve) => server!.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to resolve integration server address'); + + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; + process.env.HAPPIER_HOME_DIR = happyHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => server!.close((e) => (e ? reject(e) : resolve()))); + } + server = null; + if (happyHomeDir) await rm(happyHomeDir, { recursive: true, force: true }); + + if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = originalServerUrl; + if (originalWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = originalWebappUrl; + if (originalHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = originalHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + it('archives a session and returns a session_archive JSON envelope', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => stdout.push(args.join(' '))); + + try { + await handleSessionCommand(['archive', sessionId, '--json'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { type: 'legacy', secret: new Uint8Array(32).fill(1) }, + }), + }); + + const parsed = JSON.parse(stdout.join('\n').trim()); + expect(parsed.ok).toBe(true); + expect(parsed.kind).toBe('session_archive'); + expect(parsed.data?.sessionId).toBe(sessionId); + expect(parsed.data?.archivedAt).toBe(123); + } finally { + logSpy.mockRestore(); + } + }); + + it('unarchives a session and returns a session_unarchive JSON envelope', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => stdout.push(args.join(' '))); + + try { + await handleSessionCommand(['unarchive', sessionId, '--json'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { type: 'legacy', secret: new Uint8Array(32).fill(1) }, + }), + }); + + const parsed = JSON.parse(stdout.join('\n').trim()); + expect(parsed.ok).toBe(true); + expect(parsed.kind).toBe('session_unarchive'); + expect(parsed.data?.sessionId).toBe(sessionId); + expect(parsed.data?.archivedAt).toBe(null); + } finally { + logSpy.mockRestore(); + } + }); +}); + diff --git a/apps/cli/src/cli/commands/session/archive.ts b/apps/cli/src/cli/commands/session/archive.ts new file mode 100644 index 000000000..b70b7d10c --- /dev/null +++ b/apps/cli/src/cli/commands/session/archive.ts @@ -0,0 +1,51 @@ +import chalk from 'chalk'; + +import type { Credentials } from '@/persistence'; +import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; +import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; +import { archiveSession } from '@/sessionControl/sessionsHttp'; + +export async function cmdSessionArchive( + argv: string[], + deps: Readonly<{ readCredentialsFn: () => Promise }>, +): Promise { + const json = wantsJson(argv); + const idOrPrefix = String(argv[1] ?? '').trim(); + if (!idOrPrefix) { + throw new Error('Usage: happier session archive [--json]'); + } + + const credentials = await deps.readCredentialsFn(); + if (!credentials) { + if (json) { + printJsonEnvelope({ ok: false, kind: 'session_archive', error: { code: 'not_authenticated' } }); + return; + } + console.error(chalk.red('Error:'), 'Not authenticated. Run "happier auth login" first.'); + process.exit(1); + } + + const resolved = await resolveSessionIdOrPrefix({ credentials, idOrPrefix }); + if (!resolved.ok) { + if (json) { + printJsonEnvelope({ + ok: false, + kind: 'session_archive', + error: { code: resolved.code, ...(resolved.candidates ? { candidates: resolved.candidates } : {}) }, + }); + return; + } + throw new Error(resolved.code); + } + const sessionId = resolved.sessionId; + + const res = await archiveSession({ token: credentials.token, sessionId }); + + if (json) { + printJsonEnvelope({ ok: true, kind: 'session_archive', data: { sessionId, archivedAt: res.archivedAt } }); + return; + } + + console.log(chalk.green('✓'), `archived ${sessionId}`); +} + diff --git a/apps/cli/src/cli/commands/session/create.integration.test.ts b/apps/cli/src/cli/commands/session/create.integration.test.ts index 6a828dc45..8c80a0bed 100644 --- a/apps/cli/src/cli/commands/session/create.integration.test.ts +++ b/apps/cli/src/cli/commands/session/create.integration.test.ts @@ -28,6 +28,13 @@ describe('happier session create (integration)', () => { return; } + if (req.method === 'GET' && url.pathname === `/v2/sessions/archived`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ sessions: [], nextCursor: null, hasNext: false })); + return; + } + if (req.method === 'POST' && url.pathname === `/v1/sessions`) { const chunks: Buffer[] = []; for await (const c of req) chunks.push(Buffer.from(c)); @@ -111,7 +118,7 @@ describe('happier session create (integration)', () => { }); try { - await handleSessionCommand(['create', '--tag', 'MyTag', '--json'], { + await handleSessionCommand(['create', '--tag', 'MyTag', '--title', 'My Title', '--json'], { readCredentialsFn: async () => ({ token: 'token_test', encryption: { @@ -129,6 +136,7 @@ describe('happier session create (integration)', () => { expect(parsed.data?.created).toBe(true); expect(parsed.data?.session?.id).toBe('sess_integration_create_123'); expect(parsed.data?.session?.tag).toBe('MyTag'); + expect(parsed.data?.session?.title).toBe('My Title'); expect(parsed.data?.session?.encryption?.type).toBe('dataKey'); } finally { logSpy.mockRestore(); diff --git a/apps/cli/src/cli/commands/session/create.plain.integration.test.ts b/apps/cli/src/cli/commands/session/create.plain.integration.test.ts new file mode 100644 index 000000000..53fc8abfb --- /dev/null +++ b/apps/cli/src/cli/commands/session/create.plain.integration.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { deriveBoxPublicKeyFromSeed } from '@happier-dev/protocol'; + +describe('happier session create plaintext sessions (integration)', () => { + const originalServerUrl = process.env.HAPPIER_SERVER_URL; + const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; + const originalHomeDir = process.env.HAPPIER_HOME_DIR; + let server: Server | null = null; + let happyHomeDir = ''; + + beforeEach(async () => { + happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-create-plain-')); + + const sessionId = 'sess_integration_create_plain_123'; + + server = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === `/v2/sessions`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ sessions: [], nextCursor: null, hasNext: false })); + return; + } + + if (req.method === 'GET' && url.pathname === `/v2/sessions/archived`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ sessions: [], nextCursor: null, hasNext: false })); + return; + } + + if (req.method === 'GET' && url.pathname === `/v1/features`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + features: {}, + capabilities: { + encryption: { storagePolicy: 'optional', allowAccountOptOut: true, defaultAccountMode: 'e2ee' }, + }, + }), + ); + return; + } + + if (req.method === 'GET' && url.pathname === `/v1/account/encryption`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ mode: 'plain', updatedAt: 0 })); + return; + } + + if (req.method === 'POST' && url.pathname === `/v1/sessions`) { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(Buffer.from(c)); + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + + if (body.encryptionMode !== 'plain') { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'expected_plain_encryption_mode' })); + return; + } + + // Plain sessions must store metadata as JSON (not ciphertext). + let parsedMeta: any = null; + try { + parsedMeta = JSON.parse(String(body.metadata ?? 'null')); + } catch { + parsedMeta = null; + } + if (!parsedMeta || typeof parsedMeta !== 'object') { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'expected_plain_metadata_json' })); + return; + } + + if (body.dataEncryptionKey !== null) { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'expected_no_data_key_for_plain' })); + return; + } + + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: body.metadata, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + encryptionMode: 'plain', + share: null, + }, + }), + ); + return; + } + + res.statusCode = 404; + res.end(); + }); + + await new Promise((resolve) => { + server!.listen(0, '127.0.0.1', () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve session control integration test server address'); + } + + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; + process.env.HAPPIER_HOME_DIR = happyHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => { + server!.close((error) => (error ? reject(error) : resolve())); + }); + } + server = null; + if (happyHomeDir) { + await rm(happyHomeDir, { recursive: true, force: true }); + } + + if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = originalServerUrl; + if (originalWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = originalWebappUrl; + if (originalHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = originalHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + it('creates/loads sessions using encryptionMode=plain when the server supports optional plaintext storage and the account mode is plain', async () => { + const { handleSessionCommand } = await import('./index'); + + const machineKeySeed = new Uint8Array(32).fill(8); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => { + stdout.push(args.join(' ')); + }); + + try { + await handleSessionCommand(['create', '--tag', 'MyTag', '--title', 'My Title', '--json'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { + type: 'dataKey', + publicKey: deriveBoxPublicKeyFromSeed(machineKeySeed), + machineKey: machineKeySeed, + }, + }), + }); + + const parsed = JSON.parse(stdout.join('\n').trim()); + expect(parsed.v).toBe(1); + expect(parsed.ok).toBe(true); + expect(parsed.kind).toBe('session_create'); + expect(parsed.data?.created).toBe(true); + expect(parsed.data?.session?.id).toBe('sess_integration_create_plain_123'); + expect(parsed.data?.session?.tag).toBe('MyTag'); + expect(parsed.data?.session?.title).toBe('My Title'); + expect(parsed.data?.session?.encryptionMode).toBe('plain'); + } finally { + logSpy.mockRestore(); + } + }); +}); + diff --git a/apps/cli/src/cli/commands/session/create.ts b/apps/cli/src/cli/commands/session/create.ts index 8d0b4de2e..074e53552 100644 --- a/apps/cli/src/cli/commands/session/create.ts +++ b/apps/cli/src/cli/commands/session/create.ts @@ -2,8 +2,6 @@ import chalk from 'chalk'; import os from 'node:os'; import type { Credentials } from '@/persistence'; -import { resolveSessionEncryptionContext } from '@/api/client/encryptionKey'; -import { encodeBase64, encrypt } from '@/api/encryption'; import { fetchSessionsPage, getOrCreateSessionByTag } from '@/sessionControl/sessionsHttp'; import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; import { summarizeSessionRecord } from '@/sessionControl/sessionSummary'; @@ -15,18 +13,23 @@ async function tagExists(params: Readonly<{ credentials: Credentials; tag: strin const maxPagesParsed = maxPagesRaw ? Number.parseInt(maxPagesRaw, 10) : NaN; const maxPages = Number.isFinite(maxPagesParsed) && maxPagesParsed > 0 ? Math.min(50, maxPagesParsed) : 10; - let cursor: string | undefined; - for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { - const page = await fetchSessionsPage({ token: params.credentials.token, cursor, limit: 200 }); - for (const row of page.sessions) { - const meta = tryDecryptSessionMetadata({ credentials: params.credentials, rawSession: row }); - const rowTag = typeof (meta as any)?.tag === 'string' ? String((meta as any).tag).trim() : ''; - if (rowTag && rowTag === params.tag) return true; + const scan = async (archivedOnly: boolean): Promise => { + let cursor: string | undefined; + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const page = await fetchSessionsPage({ token: params.credentials.token, cursor, limit: 200, archivedOnly }); + for (const row of page.sessions) { + const meta = tryDecryptSessionMetadata({ credentials: params.credentials, rawSession: row }); + const rowTag = typeof (meta as any)?.tag === 'string' ? String((meta as any).tag).trim() : ''; + if (rowTag && rowTag === params.tag) return true; + } + if (!page.hasNext || !page.nextCursor) break; + cursor = page.nextCursor; } - if (!page.hasNext || !page.nextCursor) break; - cursor = page.nextCursor; - } - return false; + return false; + }; + + if (await scan(false)) return true; + return await scan(true); } export async function cmdSessionCreate( @@ -37,10 +40,13 @@ export async function cmdSessionCreate( const tag = (readFlagValue(argv, '--tag') ?? '').trim(); const path = (readFlagValue(argv, '--path') ?? process.cwd()).trim(); const host = (readFlagValue(argv, '--host') ?? os.hostname()).trim(); + const title = (readFlagValue(argv, '--title') ?? '').trim(); const noLoadExisting = hasFlag(argv, '--no-load-existing'); if (!tag) { - throw new Error('Usage: happier session create --tag [--path ] [--host ] [--no-load-existing] [--json]'); + throw new Error( + 'Usage: happier session create --tag [--title ] [--path ] [--host ] [--no-load-existing] [--json]', + ); } const credentials = await deps.readCredentialsFn(); @@ -63,17 +69,16 @@ export async function cmdSessionCreate( process.exit(1); } - const { encryptionKey, encryptionVariant, dataEncryptionKey } = resolveSessionEncryptionContext(credentials); - - const metadataCiphertext = encodeBase64(encrypt(encryptionKey, encryptionVariant, { tag, path, host })); - const dataEncryptionKeyBase64 = dataEncryptionKey ? encodeBase64(dataEncryptionKey) : null; - const { session } = await getOrCreateSessionByTag({ credentials, tag, - metadataCiphertext, - agentStateCiphertext: null, - dataEncryptionKey: dataEncryptionKeyBase64, + metadata: { + tag, + path, + host, + ...(title ? { summary: { text: title, updatedAt: Date.now() } } : {}), + }, + agentState: null, }); const summary = summarizeSessionRecord({ credentials, session }); @@ -87,4 +92,3 @@ export async function cmdSessionCreate( console.log(chalk.green('✓'), created ? 'session created' : 'session loaded'); console.log(JSON.stringify({ created, session: summary }, null, 2)); } - diff --git a/apps/cli/src/cli/commands/session/delegate/start.integration.test.ts b/apps/cli/src/cli/commands/session/delegate/start.integration.test.ts index d714e1ff9..48cc25cf6 100644 --- a/apps/cli/src/cli/commands/session/delegate/start.integration.test.ts +++ b/apps/cli/src/cli/commands/session/delegate/start.integration.test.ts @@ -54,11 +54,19 @@ describe('happier session delegate start (integration)', () => { JSON.stringify({ session: { id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + share: null, }, }), ); diff --git a/apps/cli/src/cli/commands/session/executionRunGet.integration.test.ts b/apps/cli/src/cli/commands/session/executionRunGet.integration.test.ts index aa34da7bf..f6777f285 100644 --- a/apps/cli/src/cli/commands/session/executionRunGet.integration.test.ts +++ b/apps/cli/src/cli/commands/session/executionRunGet.integration.test.ts @@ -61,11 +61,19 @@ describe('happier session execution-run-get (integration)', () => { JSON.stringify({ session: { id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + share: null, }, }), ); diff --git a/apps/cli/src/cli/commands/session/handleSessionCommand.ts b/apps/cli/src/cli/commands/session/handleSessionCommand.ts index a11637475..1d2c25928 100644 --- a/apps/cli/src/cli/commands/session/handleSessionCommand.ts +++ b/apps/cli/src/cli/commands/session/handleSessionCommand.ts @@ -7,6 +7,11 @@ import { cmdSessionCreate } from './create'; import { cmdSessionSend } from './send'; import { cmdSessionWait } from './wait'; import { cmdSessionStop } from './stop'; +import { cmdSessionArchive } from './archive'; +import { cmdSessionUnarchive } from './unarchive'; +import { cmdSessionSetTitle } from './setTitle'; +import { cmdSessionSetPermissionMode } from './setPermissionMode'; +import { cmdSessionSetModel } from './setModel'; import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; import { cmdSessionRunGet } from './run/get'; import { cmdSessionRunList } from './run/list'; @@ -32,9 +37,14 @@ function inferSessionKind(argv: readonly string[]): string { if (sub === 'list') return 'session_list'; if (sub === 'status') return 'session_status'; if (sub === 'create') return 'session_create'; + if (sub === 'set-title') return 'session_set_title'; + if (sub === 'set-permission-mode') return 'session_set_permission_mode'; + if (sub === 'set-model') return 'session_set_model'; if (sub === 'send') return 'session_send'; if (sub === 'wait') return 'session_wait'; if (sub === 'stop') return 'session_stop'; + if (sub === 'archive') return 'session_archive'; + if (sub === 'unarchive') return 'session_unarchive'; if (sub === 'history') return 'session_history'; if (sub === 'actions') { const actionSub = String(argv[1] ?? '').trim(); @@ -75,8 +85,13 @@ export async function handleSessionCommand( try { const subcommand = String(argv[0] ?? '').trim(); if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') { - console.log('happier session list [--active] [--limit N] [--cursor C] [--json]'); + console.log('happier session list [--active] [--archived] [--limit N] [--cursor C] [--include-system] [--json]'); console.log('happier session history [--limit N] [--format compact|raw] [--include-meta] [--include-structured-payload] [--json]'); + console.log('happier session set-title [--json]'); + console.log('happier session set-permission-mode <session-id> <mode> [--json]'); + console.log('happier session set-model <session-id> <model-id> [--json]'); + console.log('happier session archive <session-id> [--json]'); + console.log('happier session unarchive <session-id> [--json]'); console.log('happier session review start <session-id> --engines <id1,id2> --instructions <text> [--json]'); console.log('happier session plan start <session-id> --backends <id1,id2> --instructions <text> [--json]'); console.log('happier session delegate start <session-id> --backends <id1,id2> --instructions <text> [--json]'); @@ -103,6 +118,15 @@ export async function handleSessionCommand( case 'create': await cmdSessionCreate(argv, { readCredentialsFn }); return; + case 'set-title': + await cmdSessionSetTitle(argv, { readCredentialsFn }); + return; + case 'set-permission-mode': + await cmdSessionSetPermissionMode(argv, { readCredentialsFn }); + return; + case 'set-model': + await cmdSessionSetModel(argv, { readCredentialsFn }); + return; case 'send': await cmdSessionSend(argv, { readCredentialsFn }); return; @@ -112,6 +136,12 @@ export async function handleSessionCommand( case 'stop': await cmdSessionStop(argv, { readCredentialsFn }); return; + case 'archive': + await cmdSessionArchive(argv, { readCredentialsFn }); + return; + case 'unarchive': + await cmdSessionUnarchive(argv, { readCredentialsFn }); + return; case 'history': await cmdSessionHistory(argv, { readCredentialsFn }); return; diff --git a/apps/cli/src/cli/commands/session/history.integration.test.ts b/apps/cli/src/cli/commands/session/history.integration.test.ts index fc2da4b33..024386846 100644 --- a/apps/cli/src/cli/commands/session/history.integration.test.ts +++ b/apps/cli/src/cli/commands/session/history.integration.test.ts @@ -59,6 +59,24 @@ describe('happier session history (integration)', () => { 'base64', ); + const sessionRow = { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + archivedAt: null, + metadata: metadataCiphertext, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: dataEncryptionKeyBase64, + share: null, + }; + server = createServer((req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); if (req.method === 'GET' && url.pathname === `/v2/sessions`) { @@ -67,22 +85,7 @@ describe('happier session history (integration)', () => { res.end( JSON.stringify({ sessions: [ - { - id: sessionId, - seq: 1, - createdAt: 1, - updatedAt: 2, - active: false, - activeAt: 0, - metadata: metadataCiphertext, - metadataVersion: 0, - agentState: null, - agentStateVersion: 0, - pendingCount: 0, - pendingVersion: 0, - dataEncryptionKey: dataEncryptionKeyBase64, - share: null, - }, + sessionRow, ], nextCursor: null, hasNext: false, @@ -90,19 +93,18 @@ describe('happier session history (integration)', () => { ); return; } + if (req.method === 'GET' && url.pathname === `/v2/sessions/archived`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ sessions: [], nextCursor: null, hasNext: false })); + return; + } if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { res.statusCode = 200; res.setHeader('content-type', 'application/json'); res.end( JSON.stringify({ - session: { - id: sessionId, - metadata: metadataCiphertext, - metadataVersion: 0, - agentState: null, - agentStateVersion: 0, - dataEncryptionKey: dataEncryptionKeyBase64, - }, + session: sessionRow, }), ); return; @@ -231,3 +233,168 @@ describe('happier session history (integration)', () => { } }); }); + +describe('happier session history (plaintext integration)', () => { + const originalServerUrl = process.env.HAPPIER_SERVER_URL; + const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; + const originalHomeDir = process.env.HAPPIER_HOME_DIR; + let server: Server | null = null; + let happyHomeDir = ''; + + beforeEach(async () => { + happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-history-plain-')); + + const sessionId = 'sess_integration_history_plain_123'; + const metadataPlaintext = JSON.stringify({ + path: '/tmp/happier-session-control-integration', + flavor: 'claude', + }); + + const sessionRow = { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + archivedAt: null, + encryptionMode: 'plain', + metadata: metadataPlaintext, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + share: null, + }; + + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + if (req.method === 'GET' && url.pathname === `/v2/sessions`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + sessions: [ + sessionRow, + ], + nextCursor: null, + hasNext: false, + }), + ); + return; + } + if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + session: sessionRow, + }), + ); + return; + } + + if (req.method === 'GET' && url.pathname === `/v1/sessions/${sessionId}/messages`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + messages: [ + { + seq: 3, + createdAt: 1700000000000, + content: { + t: 'plain', + v: { + role: 'agent', + content: { type: 'text', text: 'hello' }, + meta: { + happier: { + kind: 'review_findings.v1', + payload: { findings: [{ id: 'f1', title: 't', severity: 'warning' }] }, + }, + }, + }, + }, + }, + ], + }), + ); + return; + } + + res.statusCode = 404; + res.end(); + }); + + await new Promise<void>((resolve) => { + server!.listen(0, '127.0.0.1', () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve session control integration test server address'); + } + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; + process.env.HAPPIER_HOME_DIR = happyHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + afterEach(async () => { + if (server) { + await new Promise<void>((resolve, reject) => { + server!.close((error) => (error ? reject(error) : resolve())); + }); + } + server = null; + if (happyHomeDir) { + await rm(happyHomeDir, { recursive: true, force: true }); + } + + if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = originalServerUrl; + if (originalWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = originalWebappUrl; + if (originalHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = originalHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + it('returns compact history for plaintext sessions', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => { + stdout.push(args.join(' ')); + }); + + try { + await handleSessionCommand( + ['history', 'sess_integration_history_plain_123', '--limit', '10', '--json'], + { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { + type: 'legacy', + secret: new Uint8Array(32).fill(1), + }, + }), + }, + ); + const parsedDefault = JSON.parse(stdout.join('\n').trim()); + expect(parsedDefault.ok).toBe(true); + expect(parsedDefault.kind).toBe('session_history'); + expect(parsedDefault.data?.format).toBe('compact'); + expect(parsedDefault.data?.sessionId).toBe('sess_integration_history_plain_123'); + expect(parsedDefault.data?.messages?.[0]?.structuredKind).toBe('review_findings.v1'); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/apps/cli/src/cli/commands/session/history.ts b/apps/cli/src/cli/commands/session/history.ts index 6f4b0c1c8..3b93fbffd 100644 --- a/apps/cli/src/cli/commands/session/history.ts +++ b/apps/cli/src/cli/commands/session/history.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import type { Credentials } from '@/persistence'; import { fetchEncryptedTranscriptMessages } from '@/session/replay/fetchEncryptedTranscriptMessages'; import { decodeBase64, decrypt } from '@/api/encryption'; +import { SessionMessageContentSchema } from '@/api/types'; import { readIntFlagValue, readFlagValue, hasFlag } from '@/sessionControl/argvFlags'; import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; import { fetchSessionById } from '@/sessionControl/sessionsHttp'; @@ -86,6 +87,20 @@ function extractRawRow(params: Readonly<{ }; } +function tryResolveDecryptedTranscriptPayload(params: Readonly<{ + content: unknown; + ctx: Readonly<{ encryptionKey: Uint8Array; encryptionVariant: 'legacy' | 'dataKey' }>; +}>): unknown | null { + const parsed = SessionMessageContentSchema.safeParse(params.content); + if (!parsed.success) return null; + if (parsed.data.t === 'plain') return parsed.data.v; + try { + return decrypt(params.ctx.encryptionKey, params.ctx.encryptionVariant, decodeBase64(parsed.data.c, 'base64')); + } catch { + return null; + } +} + export async function cmdSessionHistory( argv: string[], deps: Readonly<{ readCredentialsFn: () => Promise<Credentials | null> }>, @@ -150,10 +165,8 @@ export async function cmdSessionHistory( for (let i = 0; i < rows.length; i += 1) { const row = rows[i]!; const content = (row as any)?.content; - if (!content || typeof content !== 'object') continue; - if ((content as any).t !== 'encrypted' || typeof (content as any).c !== 'string') continue; - - const decrypted = decrypt(ctx.encryptionKey, ctx.encryptionVariant, decodeBase64(String((content as any).c), 'base64')); + const decrypted = tryResolveDecryptedTranscriptPayload({ content, ctx }); + if (!decrypted) continue; const createdAt = typeof (row as any)?.createdAt === 'number' ? (row as any).createdAt : 0; const seq = (row as any)?.seq; const id = typeof seq === 'number' || typeof seq === 'string' ? String(seq) : String(i); @@ -187,10 +200,8 @@ export async function cmdSessionHistory( for (let i = 0; i < rows.length; i += 1) { const row = rows[i]!; const content = (row as any)?.content; - if (!content || typeof content !== 'object') continue; - if ((content as any).t !== 'encrypted' || typeof (content as any).c !== 'string') continue; - - const decrypted = decrypt(ctx.encryptionKey, ctx.encryptionVariant, decodeBase64(String((content as any).c), 'base64')); + const decrypted = tryResolveDecryptedTranscriptPayload({ content, ctx }); + if (!decrypted) continue; const createdAt = typeof (row as any)?.createdAt === 'number' ? (row as any).createdAt : 0; const seq = (row as any)?.seq; const id = typeof seq === 'number' || typeof seq === 'string' ? String(seq) : String(i); diff --git a/apps/cli/src/cli/commands/session/list.integration.test.ts b/apps/cli/src/cli/commands/session/list.integration.test.ts index 904b124e7..5e53fadfe 100644 --- a/apps/cli/src/cli/commands/session/list.integration.test.ts +++ b/apps/cli/src/cli/commands/session/list.integration.test.ts @@ -16,10 +16,12 @@ describe('happier session list (integration)', () => { let server: Server | null = null; let happyHomeDir = ''; + const normalSessionId = 'sess_integration_list_123'; + const systemSessionId = 'sess_integration_system_456'; + const archivedSessionId = 'sess_integration_archived_999'; + beforeEach(async () => { happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-list-')); - - const sessionId = 'sess_integration_list_123'; const dek = new Uint8Array(32).fill(3); const machineKeySeed = new Uint8Array(32).fill(8); const recipientPublicKey = deriveBoxPublicKeyFromSeed(machineKeySeed); @@ -30,13 +32,44 @@ describe('happier session list (integration)', () => { }); const { encodeBase64: encodeBase64Session, encryptWithDataKey } = await import('@/api/encryption'); - const metadataCiphertext = encodeBase64Session( + const normalMetadata = encodeBase64Session( encryptWithDataKey( { path: '/tmp/happier-session-control-integration', flavor: 'claude', tag: 'MyTag', host: 'host1', + summary: { text: 'My Title', updatedAt: 123 }, + }, + dek, + ), + 'base64', + ); + const systemMetadata = encodeBase64Session( + encryptWithDataKey( + { + path: '/tmp/happier-session-control-system', + flavor: 'claude', + tag: 'Carrier', + host: 'host1', + systemSessionV1: { + v: 1, + key: 'voice_carrier', + hidden: true, + }, + }, + dek, + ), + 'base64', + ); + const archivedMetadata = encodeBase64Session( + encryptWithDataKey( + { + path: '/tmp/happier-session-control-archived', + flavor: 'claude', + tag: 'ArchivedTag', + host: 'host1', + summary: { text: 'Archived Title', updatedAt: 456 }, }, dek, ), @@ -53,13 +86,60 @@ describe('happier session list (integration)', () => { JSON.stringify({ sessions: [ { - id: sessionId, + id: normalSessionId, seq: 1, createdAt: 1, updatedAt: 2, active: false, activeAt: 0, - metadata: metadataCiphertext, + metadata: normalMetadata, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: dataEncryptionKeyBase64, + share: null, + }, + { + id: systemSessionId, + seq: 2, + createdAt: 3, + updatedAt: 4, + active: false, + activeAt: 3, + metadata: systemMetadata, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: dataEncryptionKeyBase64, + share: null, + }, + ], + nextCursor: null, + hasNext: false, + }), + ); + return; + } + + if (req.method === 'GET' && url.pathname === `/v2/sessions/archived`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + sessions: [ + { + id: archivedSessionId, + seq: 3, + createdAt: 10, + updatedAt: 11, + active: false, + activeAt: 0, + archivedAt: 12, + metadata: archivedMetadata, metadataVersion: 0, agentState: null, agentStateVersion: 0, @@ -144,11 +224,129 @@ describe('happier session list (integration)', () => { expect(parsed.kind).toBe('session_list'); expect(parsed.data?.sessions?.[0]?.id).toBe('sess_integration_list_123'); expect(parsed.data?.sessions?.[0]?.tag).toBe('MyTag'); + expect(parsed.data?.sessions?.[0]?.title).toBe('My Title'); expect(parsed.data?.sessions?.[0]?.host).toBe('host1'); expect(parsed.data?.sessions?.[0]?.encryption?.type).toBe('dataKey'); + expect(parsed.data?.sessions?.some((s: any) => s.id === systemSessionId)).toBe(false); } finally { logSpy.mockRestore(); } }); -}); + it('supports --archived by listing /v2/sessions/archived', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => { + stdout.push(args.join(' ')); + }); + + try { + await handleSessionCommand(['list', '--archived', '--json'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { + type: 'dataKey', + publicKey: deriveBoxPublicKeyFromSeed(new Uint8Array(32).fill(8)), + machineKey: new Uint8Array(32).fill(8), + }, + }), + }); + + const parsed = JSON.parse(stdout.join('\n').trim()); + expect(parsed.ok).toBe(true); + expect(parsed.kind).toBe('session_list'); + expect(parsed.data?.sessions?.[0]?.id).toBe(archivedSessionId); + expect(parsed.data?.sessions?.[0]?.tag).toBe('ArchivedTag'); + expect(parsed.data?.sessions?.[0]?.title).toBe('Archived Title'); + } finally { + logSpy.mockRestore(); + } + }); + + it('omits system sessions without --include-system', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => { + stdout.push(args.join(' ')); + }); + + try { + await handleSessionCommand(['list'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { + type: 'dataKey', + publicKey: deriveBoxPublicKeyFromSeed(new Uint8Array(32).fill(8)), + machineKey: new Uint8Array(32).fill(8), + }, + }), + }); + + expect(stdout.join('\n')).not.toContain(systemSessionId); + expect(stdout.join('\n')).toContain(normalSessionId); + } finally { + logSpy.mockRestore(); + } + }); + + it('includes system sessions with --include-system and marks them in human output', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => { + stdout.push(args.join(' ')); + }); + + try { + await handleSessionCommand(['list', '--include-system'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { + type: 'dataKey', + publicKey: deriveBoxPublicKeyFromSeed(new Uint8Array(32).fill(8)), + machineKey: new Uint8Array(32).fill(8), + }, + }), + }); + + expect(stdout.join('\n')).toContain(`system:${'voice_carrier'}`); + expect(stdout.join('\n')).toContain(systemSessionId); + } finally { + logSpy.mockRestore(); + } + }); + + it('includes system markers in JSON when --include-system is used', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => { + stdout.push(args.join(' ')); + }); + + try { + await handleSessionCommand(['list', '--include-system', '--json'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { + type: 'dataKey', + publicKey: deriveBoxPublicKeyFromSeed(new Uint8Array(32).fill(8)), + machineKey: new Uint8Array(32).fill(8), + }, + }), + }); + + const parsed = JSON.parse(stdout.join('\n').trim()); + const systemSession = parsed.data?.sessions?.find((session: any) => session.id === systemSessionId); + expect(systemSession).toMatchObject({ + id: systemSessionId, + isSystem: true, + systemPurpose: 'voice_carrier', + }); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/apps/cli/src/cli/commands/session/list.ts b/apps/cli/src/cli/commands/session/list.ts index 5c59dd016..72a7ca95f 100644 --- a/apps/cli/src/cli/commands/session/list.ts +++ b/apps/cli/src/cli/commands/session/list.ts @@ -12,10 +12,16 @@ export async function cmdSessionList( ): Promise<void> { const json = wantsJson(argv); const activeOnly = hasFlag(argv, '--active'); + const archivedOnly = hasFlag(argv, '--archived'); + const includeSystem = hasFlag(argv, '--include-system'); const limitRaw = readIntFlagValue(argv, '--limit'); const limit = typeof limitRaw === 'number' && Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 200) : undefined; const cursor = readFlagValue(argv, '--cursor') ?? ''; + if (activeOnly && archivedOnly) { + throw new Error('Usage: happier session list [--active] [--archived] [--limit N] [--cursor C] [--include-system] [--json]'); + } + const credentials = await deps.readCredentialsFn(); if (!credentials) { if (json) { @@ -31,9 +37,12 @@ export async function cmdSessionList( ...(cursor ? { cursor } : {}), ...(limit ? { limit } : {}), activeOnly, + archivedOnly, }); - const sessions = page.sessions.map((row) => summarizeSessionRow({ credentials, row })); + const sessions = page.sessions + .map((row) => summarizeSessionRow({ credentials, row })) + .filter((session) => includeSystem || session.isSystem !== true); if (json) { printJsonEnvelope({ @@ -49,7 +58,10 @@ export async function cmdSessionList( } for (const s of sessions) { - console.log(`${s.id}${s.tag ? ` ${chalk.gray(s.tag)}` : ''}${s.path ? ` ${chalk.gray(s.path)}` : ''}`); + const systemSuffix = + includeSystem && s.isSystem + ? ` ${chalk.yellow(`[system${s.systemPurpose ? `:${s.systemPurpose}` : ''}]`)}` + : ''; + console.log(`${s.id}${systemSuffix}${s.tag ? ` ${chalk.gray(s.tag)}` : ''}${s.path ? ` ${chalk.gray(s.path)}` : ''}`); } } - diff --git a/apps/cli/src/cli/commands/session/plan/start.integration.test.ts b/apps/cli/src/cli/commands/session/plan/start.integration.test.ts index 21dfb8c71..eef6c237f 100644 --- a/apps/cli/src/cli/commands/session/plan/start.integration.test.ts +++ b/apps/cli/src/cli/commands/session/plan/start.integration.test.ts @@ -54,11 +54,19 @@ describe('happier session plan start (integration)', () => { JSON.stringify({ session: { id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + share: null, }, }), ); diff --git a/apps/cli/src/cli/commands/session/review/start.integration.test.ts b/apps/cli/src/cli/commands/session/review/start.integration.test.ts index e937bb38d..ba3f0565b 100644 --- a/apps/cli/src/cli/commands/session/review/start.integration.test.ts +++ b/apps/cli/src/cli/commands/session/review/start.integration.test.ts @@ -54,11 +54,19 @@ describe('happier session review start (integration)', () => { JSON.stringify({ session: { id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + share: null, }, }), ); diff --git a/apps/cli/src/cli/commands/session/run/action.integration.test.ts b/apps/cli/src/cli/commands/session/run/action.integration.test.ts index f33633a4c..5c990bb6d 100644 --- a/apps/cli/src/cli/commands/session/run/action.integration.test.ts +++ b/apps/cli/src/cli/commands/session/run/action.integration.test.ts @@ -40,7 +40,26 @@ describe('happier session run action (integration)', () => { if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { res.statusCode = 200; res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ session: { id: sessionId, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64 } })); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: metadataCiphertext, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: dataEncryptionKeyBase64, + share: null, + }, + }), + ); return; } res.statusCode = 404; @@ -133,4 +152,3 @@ describe('happier session run action (integration)', () => { } }); }); - diff --git a/apps/cli/src/cli/commands/session/run/send.integration.test.ts b/apps/cli/src/cli/commands/session/run/send.integration.test.ts index 675882826..4fd941fe8 100644 --- a/apps/cli/src/cli/commands/session/run/send.integration.test.ts +++ b/apps/cli/src/cli/commands/session/run/send.integration.test.ts @@ -51,7 +51,26 @@ describe('happier session run send (integration)', () => { if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { res.statusCode = 200; res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ session: { id: sessionId, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64 } })); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: metadataCiphertext, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: dataEncryptionKeyBase64, + share: null, + }, + }), + ); return; } res.statusCode = 404; @@ -137,4 +156,3 @@ describe('happier session run send (integration)', () => { } }); }); - diff --git a/apps/cli/src/cli/commands/session/run/start.integration.test.ts b/apps/cli/src/cli/commands/session/run/start.integration.test.ts index 7052f8a09..e333cdc26 100644 --- a/apps/cli/src/cli/commands/session/run/start.integration.test.ts +++ b/apps/cli/src/cli/commands/session/run/start.integration.test.ts @@ -76,6 +76,12 @@ describe('happier session run start (integration)', () => { ); return; } + if (req.method === 'GET' && url.pathname === `/v2/sessions/archived`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ sessions: [], nextCursor: null, hasNext: false })); + return; + } if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { res.statusCode = 200; res.setHeader('content-type', 'application/json'); @@ -83,11 +89,19 @@ describe('happier session run start (integration)', () => { JSON.stringify({ session: { id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + share: null, }, }), ); diff --git a/apps/cli/src/cli/commands/session/run/stop.integration.test.ts b/apps/cli/src/cli/commands/session/run/stop.integration.test.ts index e7316251e..2992219fd 100644 --- a/apps/cli/src/cli/commands/session/run/stop.integration.test.ts +++ b/apps/cli/src/cli/commands/session/run/stop.integration.test.ts @@ -40,7 +40,26 @@ describe('happier session run stop (integration)', () => { if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { res.statusCode = 200; res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ session: { id: sessionId, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64 } })); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: metadataCiphertext, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: dataEncryptionKeyBase64, + share: null, + }, + }), + ); return; } res.statusCode = 404; @@ -126,4 +145,3 @@ describe('happier session run stop (integration)', () => { } }); }); - diff --git a/apps/cli/src/cli/commands/session/run/stream.integration.test.ts b/apps/cli/src/cli/commands/session/run/stream.integration.test.ts index 00653582b..26825ae1d 100644 --- a/apps/cli/src/cli/commands/session/run/stream.integration.test.ts +++ b/apps/cli/src/cli/commands/session/run/stream.integration.test.ts @@ -78,11 +78,19 @@ describe('happier session run stream-* (integration)', () => { JSON.stringify({ session: { id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + share: null, }, }), ); diff --git a/apps/cli/src/cli/commands/session/run/wait.integration.test.ts b/apps/cli/src/cli/commands/session/run/wait.integration.test.ts index 3a4ad717d..4021214cd 100644 --- a/apps/cli/src/cli/commands/session/run/wait.integration.test.ts +++ b/apps/cli/src/cli/commands/session/run/wait.integration.test.ts @@ -40,7 +40,26 @@ describe('happier session run wait (integration)', () => { if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { res.statusCode = 200; res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ session: { id: sessionId, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64 } })); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: metadataCiphertext, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: dataEncryptionKeyBase64, + share: null, + }, + }), + ); return; } res.statusCode = 404; @@ -150,4 +169,3 @@ describe('happier session run wait (integration)', () => { } }); }); - diff --git a/apps/cli/src/cli/commands/session/runList.integration.test.ts b/apps/cli/src/cli/commands/session/runList.integration.test.ts index f7b68785a..de3de3b5f 100644 --- a/apps/cli/src/cli/commands/session/runList.integration.test.ts +++ b/apps/cli/src/cli/commands/session/runList.integration.test.ts @@ -60,11 +60,19 @@ describe('happier session run list (integration)', () => { JSON.stringify({ session: { id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + share: null, }, }), ); @@ -205,4 +213,3 @@ describe('happier session run list (integration)', () => { } }); }); - diff --git a/apps/cli/src/cli/commands/session/send.integration.test.ts b/apps/cli/src/cli/commands/session/send.integration.test.ts index cc08d9155..c8156a0fd 100644 --- a/apps/cli/src/cli/commands/session/send.integration.test.ts +++ b/apps/cli/src/cli/commands/session/send.integration.test.ts @@ -20,32 +20,52 @@ describe('happier session send (integration)', () => { const originalHomeDir = process.env.HAPPIER_HOME_DIR; let server: Server | null = null; let happyHomeDir = ''; + const receivedMessages: any[] = []; + let dek: Uint8Array | null = null; + let decodeBase64Fn: ((value: string, kind?: any) => Uint8Array) | null = null; + let decryptWithDataKeyFn: ((ciphertext: Uint8Array, dataKey: Uint8Array) => any) | null = null; beforeEach(async () => { happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-send-')); + receivedMessages.length = 0; + dek = null; + decodeBase64Fn = null; + decryptWithDataKeyFn = null; const sessionId = 'sess_integration_send_123'; - const dek = new Uint8Array(32).fill(3); + dek = new Uint8Array(32).fill(3); const machineKeySeed = new Uint8Array(32).fill(8); const recipientPublicKey = deriveBoxPublicKeyFromSeed(machineKeySeed); const envelope = sealEncryptedDataKeyEnvelopeV1({ - dataKey: dek, + dataKey: dek!, recipientPublicKey, randomBytes: (length) => new Uint8Array(length).fill(5), }); const { encodeBase64: encodeBase64Session, encryptWithDataKey, decodeBase64, decryptWithDataKey } = await import('@/api/encryption'); + decodeBase64Fn = decodeBase64; + decryptWithDataKeyFn = decryptWithDataKey; const metadataCiphertext = encodeBase64Session( - encryptWithDataKey({ path: '/tmp', tag: 'MyTag', host: 'host1' }, dek), + encryptWithDataKey( + { + path: '/tmp', + tag: 'MyTag', + host: 'host1', + permissionMode: 'safe-yolo', + permissionModeUpdatedAt: 10, + modelOverrideV1: { v: 1, updatedAt: 11, modelId: 'claude-sonnet-4-0' }, + }, + dek!, + ), 'base64', ); const dataEncryptionKeyBase64 = encodeBase64Session(envelope, 'base64'); const busyAgentStateCiphertext = encodeBase64Session( - encryptWithDataKey({ controlledByUser: false, requests: { r1: { createdAt: 1 } } }, dek), + encryptWithDataKey({ controlledByUser: false, requests: { r1: { createdAt: 1 } } }, dek!), 'base64', ); const idleAgentStateCiphertext = encodeBase64Session( - encryptWithDataKey({ controlledByUser: false, requests: {} }, dek), + encryptWithDataKey({ controlledByUser: false, requests: {} }, dek!), 'base64', ); @@ -71,6 +91,7 @@ describe('happier session send (integration)', () => { pendingCount: 0, pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + encryptionMode: 'e2ee', share: null, }, }), @@ -78,25 +99,6 @@ describe('happier session send (integration)', () => { return; } - if (req.method === 'POST' && url.pathname === `/v2/sessions/${sessionId}/messages`) { - const chunks: Buffer[] = []; - for await (const c of req) chunks.push(Buffer.from(c)); - const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); - const ciphertext = String(body.ciphertext ?? ''); - - const decrypted = decryptWithDataKey(decodeBase64(ciphertext, 'base64'), dek); - expect(decrypted).toMatchObject({ - role: 'user', - content: { type: 'text', text: 'Hello from controller' }, - }); - expect((decrypted as any)?.meta?.sentFrom).toBe('cli'); - - res.statusCode = 200; - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ didWrite: true, message: { id: 'm1', seq: 2, localId: body.localId ?? null, createdAt: 3 } })); - return; - } - res.statusCode = 404; res.end(); }); @@ -127,6 +129,10 @@ describe('happier session send (integration)', () => { handlers.set(event, list.filter((v) => v !== cb)); }); const connect = vi.fn(() => { + setTimeout(() => { + const list = handlers.get('connect') ?? []; + for (const cb of list) cb(); + }, 0); setTimeout(() => { const list = handlers.get('update') ?? []; for (const cb of list) { @@ -143,10 +149,28 @@ describe('happier session send (integration)', () => { } }, 10); }); + const emit = vi.fn((event: string, payload: any, ack?: (answer: any) => void) => { + if (event === 'message') { + const content = payload?.message; + if (content?.t === 'encrypted') { + const decrypted = decryptWithDataKeyFn!( + decodeBase64Fn!(String(content?.c ?? ''), 'base64'), + dek!, + ); + receivedMessages.push(decrypted); + } else if (content?.t === 'plain') { + receivedMessages.push(content.v); + } + ack?.({ ok: true, id: 'm1', seq: 2, localId: payload?.localId ?? null, didWrite: true }); + return; + } + ack?.({ ok: false, error: 'unsupported' }); + }); return { on, off, connect, + emit, disconnect: vi.fn(), close: vi.fn(), }; @@ -191,11 +215,22 @@ describe('happier session send (integration)', () => { }); const parsed = JSON.parse(stdout.join('\n').trim()); - expect(parsed.ok).toBe(true); + if (parsed.ok !== true) { + throw new Error(`Unexpected session_send envelope: ${JSON.stringify(parsed)}`); + } expect(parsed.kind).toBe('session_send'); expect(parsed.data?.sessionId).toBe('sess_integration_send_123'); expect(typeof parsed.data?.localId).toBe('string'); expect(parsed.data?.waited).toBe(false); + + const last = receivedMessages[receivedMessages.length - 1]; + expect(last).toMatchObject({ + role: 'user', + content: { type: 'text', text: 'Hello from controller' }, + }); + expect(last?.meta?.sentFrom).toBe('cli'); + expect(last?.meta?.permissionMode).toBe('safe-yolo'); + expect(last?.meta?.model).toBe('claude-sonnet-4-0'); } finally { logSpy.mockRestore(); } @@ -223,7 +258,9 @@ describe('happier session send (integration)', () => { }); const parsed = JSON.parse(stdout.join('\n').trim()); - expect(parsed.ok).toBe(true); + if (parsed.ok !== true) { + throw new Error(`Unexpected session_send envelope: ${JSON.stringify(parsed)}`); + } expect(parsed.kind).toBe('session_send'); expect(parsed.data?.sessionId).toBe('sess_integration_send_123'); expect(parsed.data?.waited).toBe(true); @@ -233,4 +270,40 @@ describe('happier session send (integration)', () => { process.exitCode = prevExitCode; } }); + + it('supports --permission-mode and --model overrides for a single send', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => stdout.push(args.join(' '))); + + try { + const machineKeySeed = new Uint8Array(32).fill(8); + await handleSessionCommand( + ['send', 'sess_integration_send_123', 'Hello from controller', '--permission-mode', 'bypassPermissions', '--model', 'default', '--json'], + { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { + type: 'dataKey', + publicKey: deriveBoxPublicKeyFromSeed(machineKeySeed), + machineKey: machineKeySeed, + }, + }), + }, + ); + + const parsed = JSON.parse(stdout.join('\n').trim()); + if (parsed.ok !== true) { + throw new Error(`Unexpected session_send envelope: ${JSON.stringify(parsed)}`); + } + expect(parsed.kind).toBe('session_send'); + + const last = receivedMessages[receivedMessages.length - 1]; + expect(last?.meta?.permissionMode).toBe('yolo'); + expect(last?.meta?.model).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); }); diff --git a/apps/cli/src/cli/commands/session/send.plain.integration.test.ts b/apps/cli/src/cli/commands/session/send.plain.integration.test.ts new file mode 100644 index 000000000..ebeb62eb1 --- /dev/null +++ b/apps/cli/src/cli/commands/session/send.plain.integration.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { deriveBoxPublicKeyFromSeed } from '@happier-dev/protocol'; + +const { mockIo } = vi.hoisted(() => ({ + mockIo: vi.fn(), +})); + +vi.mock('socket.io-client', () => ({ + io: mockIo, +})); + +describe('happier session send plaintext sessions (integration)', () => { + const originalServerUrl = process.env.HAPPIER_SERVER_URL; + const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; + const originalHomeDir = process.env.HAPPIER_HOME_DIR; + let server: Server | null = null; + let happyHomeDir = ''; + const receivedMessages: any[] = []; + + beforeEach(async () => { + happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-send-plain-')); + receivedMessages.length = 0; + + const sessionId = 'sess_integration_send_plain_123'; + const metadataPlain = JSON.stringify({ + path: '/tmp', + tag: 'MyTag', + host: 'host1', + permissionMode: 'safe-yolo', + permissionModeUpdatedAt: 10, + modelOverrideV1: { v: 1, updatedAt: 11, modelId: 'claude-sonnet-4-0' }, + }); + + server = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: metadataPlain, + metadataVersion: 0, + agentState: JSON.stringify({ controlledByUser: false, requests: {} }), + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + encryptionMode: 'plain', + share: null, + }, + }), + ); + return; + } + + res.statusCode = 404; + res.end(); + }); + + await new Promise<void>((resolve) => { + server!.listen(0, '127.0.0.1', () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to resolve integration server address'); + + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; + process.env.HAPPIER_HOME_DIR = happyHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + + mockIo.mockReset(); + mockIo.mockImplementation(() => { + const handlers = new Map<string, Array<(...args: any[]) => void>>(); + const on = vi.fn((event: string, cb: (...args: any[]) => void) => { + const list = handlers.get(event) ?? []; + list.push(cb); + handlers.set(event, list); + }); + const off = vi.fn((event: string, cb: (...args: any[]) => void) => { + const list = handlers.get(event) ?? []; + handlers.set(event, list.filter((v) => v !== cb)); + }); + const connect = vi.fn(() => { + setTimeout(() => { + const list = handlers.get('connect') ?? []; + for (const cb of list) cb(); + }, 0); + }); + const emit = vi.fn((event: string, payload: any, ack?: (answer: any) => void) => { + if (event === 'message') { + receivedMessages.push(payload?.message); + ack?.({ ok: true, id: 'm1', seq: 2, localId: payload?.localId ?? null, didWrite: true }); + return; + } + ack?.({ ok: false, error: 'unsupported' }); + }); + return { + on, + off, + connect, + emit, + disconnect: vi.fn(), + close: vi.fn(), + }; + }); + }); + + afterEach(async () => { + if (server) { + await new Promise<void>((resolve, reject) => server!.close((e) => (e ? reject(e) : resolve()))); + } + server = null; + if (happyHomeDir) await rm(happyHomeDir, { recursive: true, force: true }); + + if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = originalServerUrl; + if (originalWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = originalWebappUrl; + if (originalHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = originalHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + it('emits a plaintext message envelope over the socket and includes meta defaults from plaintext metadata', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => stdout.push(args.join(' '))); + + try { + const machineKeySeed = new Uint8Array(32).fill(8); + await handleSessionCommand(['send', 'sess_integration_send_plain_123', 'Hello from controller', '--json'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { + type: 'dataKey', + publicKey: deriveBoxPublicKeyFromSeed(machineKeySeed), + machineKey: machineKeySeed, + }, + }), + }); + + const parsed = JSON.parse(stdout.join('\n').trim()); + if (parsed.ok !== true) { + throw new Error(`Unexpected session_send envelope: ${JSON.stringify(parsed)}`); + } + expect(parsed.kind).toBe('session_send'); + + const last = receivedMessages[receivedMessages.length - 1]; + expect(last?.t).toBe('plain'); + expect(last?.v?.content?.text).toBe('Hello from controller'); + expect(last?.v?.meta?.permissionMode).toBe('safe-yolo'); + expect(last?.v?.meta?.model).toBe('claude-sonnet-4-0'); + } finally { + logSpy.mockRestore(); + } + }); +}); + diff --git a/apps/cli/src/cli/commands/session/send.ts b/apps/cli/src/cli/commands/session/send.ts index 1554546bb..6566a5dd6 100644 --- a/apps/cli/src/cli/commands/session/send.ts +++ b/apps/cli/src/cli/commands/session/send.ts @@ -1,13 +1,27 @@ import chalk from 'chalk'; import { randomUUID } from 'node:crypto'; +import { parsePermissionIntentAlias, resolveMetadataStringOverrideV1, resolvePermissionIntentFromSessionMetadata } from '@happier-dev/agents'; +import type { PermissionIntent } from '@happier-dev/agents'; + import type { Credentials } from '@/persistence'; import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; -import { fetchSessionById, commitSessionEncryptedMessage } from '@/sessionControl/sessionsHttp'; -import { resolveSessionEncryptionContextFromCredentials, encryptSessionPayload } from '@/sessionControl/sessionEncryptionContext'; +import { fetchSessionById } from '@/sessionControl/sessionsHttp'; +import { resolveSessionEncryptionContextFromCredentials, encryptSessionPayload, resolveSessionStoredContentEncryptionMode, tryDecryptSessionMetadata } from '@/sessionControl/sessionEncryptionContext'; import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; -import { hasFlag, readIntFlagValue } from '@/sessionControl/argvFlags'; +import { hasFlag, readIntFlagValue, readFlagValue } from '@/sessionControl/argvFlags'; import { waitForIdleViaSocket } from '@/sessionControl/sessionSocketAgentState'; +import { sendSessionMessageViaSocketCommitted } from '@/sessionControl/sessionSocketSendMessage'; + +function parsePermissionIntentOrThrow(raw: string): PermissionIntent { + const parsed = parsePermissionIntentAlias(raw); + if (!parsed) { + const err = new Error(`Invalid permission mode: ${raw}`); + (err as any).code = 'invalid_arguments'; + throw err; + } + return parsed; +} export async function cmdSessionSend( argv: string[], @@ -18,13 +32,17 @@ export async function cmdSessionSend( const message = String(argv[2] ?? '').trim(); const wait = hasFlag(argv, '--wait'); const timeoutSecondsRaw = readIntFlagValue(argv, '--timeout'); + const permissionModeFlag = (readFlagValue(argv, '--permission-mode') ?? '').trim(); + const modelFlagRaw = readFlagValue(argv, '--model'); + const hasModelFlag = modelFlagRaw !== null; + const modelFlag = typeof modelFlagRaw === 'string' ? modelFlagRaw.trim() : ''; const timeoutSeconds = typeof timeoutSecondsRaw === 'number' && Number.isFinite(timeoutSecondsRaw) && timeoutSecondsRaw > 0 ? Math.min(3600, timeoutSecondsRaw) : 300; if (!idOrPrefix || !message) { - throw new Error('Usage: happier session send <session-id-or-prefix> <message> [--wait] [--timeout <seconds>] [--json]'); + throw new Error('Usage: happier session send <session-id-or-prefix> <message> [--permission-mode <mode>] [--model <model-id>] [--wait] [--timeout <seconds>] [--json]'); } const credentials = await deps.readCredentialsFn(); @@ -62,21 +80,53 @@ export async function cmdSessionSend( } const ctx = resolveSessionEncryptionContextFromCredentials(credentials, rawSession); + const storedMode = resolveSessionStoredContentEncryptionMode(rawSession as any); const localId = randomUUID(); - const ciphertext = encryptSessionPayload({ - ctx, - payload: { - role: 'user', - content: { type: 'text', text: message }, - meta: { sentFrom: 'cli', source: 'cli' }, + + const decryptedMetadata = tryDecryptSessionMetadata({ credentials, rawSession }); + + const permissionIntent = (() => { + if (permissionModeFlag) return parsePermissionIntentOrThrow(permissionModeFlag); + const resolved = resolvePermissionIntentFromSessionMetadata(decryptedMetadata); + return resolved?.intent ?? 'default'; + })(); + + const modelId = (() => { + if (hasModelFlag) { + if (!modelFlag) { + const err = new Error('Invalid --model'); + (err as any).code = 'invalid_arguments'; + throw err; + } + return modelFlag; + } + const resolved = resolveMetadataStringOverrideV1(decryptedMetadata, 'modelOverrideV1', 'modelId'); + return resolved?.value ?? ''; + })(); + + const record: any = { + role: 'user', + content: { type: 'text', text: message }, + meta: { + sentFrom: 'cli', + source: 'cli', + permissionMode: permissionIntent, + ...(modelId && modelId !== 'default' ? { model: modelId } : {}), }, - }); + }; + + const content = + storedMode === 'plain' + ? ({ t: 'plain', v: record } as const) + : ({ t: 'encrypted', c: encryptSessionPayload({ ctx, payload: record }) } as const); - await commitSessionEncryptedMessage({ + await sendSessionMessageViaSocketCommitted({ token: credentials.token, sessionId, - ciphertext, + content, localId, + sentFrom: 'cli', + permissionMode: permissionIntent, }); let waited = false; @@ -88,6 +138,7 @@ export async function cmdSessionSend( token: credentials.token, sessionId, ctx, + sessionEncryptionMode: storedMode, timeoutMs: timeoutSeconds * 1000, initialAgentStateCiphertextBase64: agentStateCiphertext && agentStateCiphertext.length > 0 ? agentStateCiphertext : null, }); diff --git a/apps/cli/src/cli/commands/session/setModel.integration.test.ts b/apps/cli/src/cli/commands/session/setModel.integration.test.ts new file mode 100644 index 000000000..273835c9d --- /dev/null +++ b/apps/cli/src/cli/commands/session/setModel.integration.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const { mockIo } = vi.hoisted(() => ({ + mockIo: vi.fn(), +})); + +vi.mock('socket.io-client', () => ({ + io: mockIo, +})); + +describe('happier session set-model (integration)', () => { + const originalServerUrl = process.env.HAPPIER_SERVER_URL; + const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; + const originalHomeDir = process.env.HAPPIER_HOME_DIR; + let server: Server | null = null; + let happyHomeDir = ''; + + const sessionId = 'sess_integration_set_model_123'; + + beforeEach(async () => { + happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-set-model-')); + + const secret = new Uint8Array(32).fill(9); + const { encodeBase64, encryptLegacy } = await import('@/api/encryption'); + const metadataCiphertext = encodeBase64(encryptLegacy({ path: '/tmp', host: 'host1', tag: 'MyTag' }, secret), 'base64'); + + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: metadataCiphertext, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + share: null, + archivedAt: null, + }, + }), + ); + return; + } + res.statusCode = 404; + res.end(); + }); + + await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to resolve integration server address'); + + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; + process.env.HAPPIER_HOME_DIR = happyHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + + mockIo.mockReset(); + mockIo.mockImplementation(() => { + const handlers = new Map<string, Array<(...args: any[]) => void>>(); + const on = vi.fn((event: string, cb: (...args: any[]) => void) => { + const list = handlers.get(event) ?? []; + list.push(cb); + handlers.set(event, list); + }); + const off = vi.fn((event: string, cb: (...args: any[]) => void) => { + const list = handlers.get(event) ?? []; + handlers.set(event, list.filter((v) => v !== cb)); + }); + const connect = vi.fn(() => { + const list = handlers.get('connect') ?? []; + for (const cb of list) cb(); + }); + const emit = vi.fn(async (event: string, ...args: any[]) => { + if (event !== 'update-metadata') return; + const [data, callback] = args; + const { decodeBase64, decryptLegacy } = await import('@/api/encryption'); + const decrypted = decryptLegacy(decodeBase64(String(data?.metadata ?? ''), 'base64'), secret); + + expect(decrypted?.modelOverrideV1?.v).toBe(1); + expect(decrypted?.modelOverrideV1?.modelId).toBe('claude-sonnet-4-0'); + expect(typeof decrypted?.modelOverrideV1?.updatedAt).toBe('number'); + + if (typeof callback === 'function') { + callback({ result: 'success', version: 1, metadata: data.metadata }); + } + }); + + return { + on, + off, + connect, + emit, + disconnect: vi.fn(), + close: vi.fn(), + }; + }); + }); + + afterEach(async () => { + if (server) { + await new Promise<void>((resolve, reject) => server!.close((e) => (e ? reject(e) : resolve()))); + } + server = null; + if (happyHomeDir) await rm(happyHomeDir, { recursive: true, force: true }); + + if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = originalServerUrl; + if (originalWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = originalWebappUrl; + if (originalHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = originalHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + it('publishes model override to encrypted metadata via update-metadata', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => stdout.push(args.join(' '))); + + try { + await handleSessionCommand(['set-model', sessionId, 'claude-sonnet-4-0', '--json'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { type: 'legacy', secret: new Uint8Array(32).fill(9) }, + }), + }); + + const parsed = JSON.parse(stdout.join('\n').trim()); + expect(parsed.ok).toBe(true); + expect(parsed.kind).toBe('session_set_model'); + expect(parsed.data?.sessionId).toBe(sessionId); + expect(parsed.data?.modelId).toBe('claude-sonnet-4-0'); + } finally { + logSpy.mockRestore(); + } + }); +}); + diff --git a/apps/cli/src/cli/commands/session/setModel.ts b/apps/cli/src/cli/commands/session/setModel.ts new file mode 100644 index 000000000..2ece9733e --- /dev/null +++ b/apps/cli/src/cli/commands/session/setModel.ts @@ -0,0 +1,91 @@ +import chalk from 'chalk'; + +import { computeNextMetadataStringOverrideV1 } from '@happier-dev/agents'; + +import type { Credentials } from '@/persistence'; +import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; +import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; +import { fetchSessionById } from '@/sessionControl/sessionsHttp'; +import { updateSessionMetadataWithRetry } from '@/sessionControl/updateSessionMetadataWithRetry'; + +function normalizeModelIdOrThrow(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + const err = new Error('Missing model id'); + (err as any).code = 'invalid_arguments'; + throw err; + } + return trimmed; +} + +export async function cmdSessionSetModel( + argv: string[], + deps: Readonly<{ readCredentialsFn: () => Promise<Credentials | null> }>, +): Promise<void> { + const json = wantsJson(argv); + const idOrPrefix = String(argv[1] ?? '').trim(); + const rawModelId = String(argv[2] ?? '').trim(); + if (!idOrPrefix || !rawModelId) { + throw new Error('Usage: happier session set-model <session-id-or-prefix> <model-id> [--json]'); + } + + const modelId = normalizeModelIdOrThrow(rawModelId); + + const credentials = await deps.readCredentialsFn(); + if (!credentials) { + if (json) { + printJsonEnvelope({ ok: false, kind: 'session_set_model', error: { code: 'not_authenticated' } }); + return; + } + console.error(chalk.red('Error:'), 'Not authenticated. Run "happier auth login" first.'); + process.exit(1); + } + + const resolved = await resolveSessionIdOrPrefix({ credentials, idOrPrefix }); + if (!resolved.ok) { + if (json) { + printJsonEnvelope({ + ok: false, + kind: 'session_set_model', + error: { code: resolved.code, ...(resolved.candidates ? { candidates: resolved.candidates } : {}) }, + }); + return; + } + throw new Error(resolved.code); + } + const sessionId = resolved.sessionId; + + const rawSession = await fetchSessionById({ token: credentials.token, sessionId }); + if (!rawSession) { + if (json) { + printJsonEnvelope({ ok: false, kind: 'session_set_model', error: { code: 'session_not_found', sessionId } }); + return; + } + console.error(chalk.red('Error:'), `Session not found: ${sessionId}`); + process.exit(1); + } + + const updatedAt = Date.now(); + await updateSessionMetadataWithRetry({ + token: credentials.token, + credentials, + sessionId, + rawSession, + updater: (metadata) => + computeNextMetadataStringOverrideV1({ + metadata, + overrideKey: 'modelOverrideV1', + valueKey: 'modelId', + value: modelId, + updatedAt, + }), + }); + + if (json) { + printJsonEnvelope({ ok: true, kind: 'session_set_model', data: { sessionId, modelId, updatedAt } }); + return; + } + + console.log(chalk.green('✓'), `model set for ${sessionId}: ${modelId}`); +} + diff --git a/apps/cli/src/cli/commands/session/setPermissionMode.integration.test.ts b/apps/cli/src/cli/commands/session/setPermissionMode.integration.test.ts new file mode 100644 index 000000000..70237746c --- /dev/null +++ b/apps/cli/src/cli/commands/session/setPermissionMode.integration.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const { mockIo } = vi.hoisted(() => ({ + mockIo: vi.fn(), +})); + +vi.mock('socket.io-client', () => ({ + io: mockIo, +})); + +describe('happier session set-permission-mode (integration)', () => { + const originalServerUrl = process.env.HAPPIER_SERVER_URL; + const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; + const originalHomeDir = process.env.HAPPIER_HOME_DIR; + let server: Server | null = null; + let happyHomeDir = ''; + + const sessionId = 'sess_integration_set_perm_123'; + + beforeEach(async () => { + happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-set-perm-')); + + const secret = new Uint8Array(32).fill(7); + const { encodeBase64, encryptLegacy } = await import('@/api/encryption'); + const metadataCiphertext = encodeBase64(encryptLegacy({ path: '/tmp', host: 'host1', tag: 'MyTag' }, secret), 'base64'); + + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: metadataCiphertext, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + share: null, + archivedAt: null, + }, + }), + ); + return; + } + res.statusCode = 404; + res.end(); + }); + + await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to resolve integration server address'); + + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; + process.env.HAPPIER_HOME_DIR = happyHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + + mockIo.mockReset(); + mockIo.mockImplementation(() => { + const handlers = new Map<string, Array<(...args: any[]) => void>>(); + const on = vi.fn((event: string, cb: (...args: any[]) => void) => { + const list = handlers.get(event) ?? []; + list.push(cb); + handlers.set(event, list); + }); + const off = vi.fn((event: string, cb: (...args: any[]) => void) => { + const list = handlers.get(event) ?? []; + handlers.set(event, list.filter((v) => v !== cb)); + }); + const connect = vi.fn(() => { + const list = handlers.get('connect') ?? []; + for (const cb of list) cb(); + }); + const emit = vi.fn(async (event: string, ...args: any[]) => { + if (event !== 'update-metadata') return; + const [data, callback] = args; + const { decodeBase64, decryptLegacy } = await import('@/api/encryption'); + const decrypted = decryptLegacy(decodeBase64(String(data?.metadata ?? ''), 'base64'), secret); + + // Legacy provider token should be persisted as provider-agnostic intent. + expect(decrypted?.permissionMode).toBe('safe-yolo'); + expect(typeof decrypted?.permissionModeUpdatedAt).toBe('number'); + + if (typeof callback === 'function') { + callback({ result: 'success', version: 1, metadata: data.metadata }); + } + }); + + return { + on, + off, + connect, + emit, + disconnect: vi.fn(), + close: vi.fn(), + }; + }); + }); + + afterEach(async () => { + if (server) { + await new Promise<void>((resolve, reject) => server!.close((e) => (e ? reject(e) : resolve()))); + } + server = null; + if (happyHomeDir) await rm(happyHomeDir, { recursive: true, force: true }); + + if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = originalServerUrl; + if (originalWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = originalWebappUrl; + if (originalHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = originalHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + it('publishes permission mode intent to encrypted metadata via update-metadata', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => stdout.push(args.join(' '))); + + try { + await handleSessionCommand(['set-permission-mode', sessionId, 'acceptEdits', '--json'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { type: 'legacy', secret: new Uint8Array(32).fill(7) }, + }), + }); + + const parsed = JSON.parse(stdout.join('\n').trim()); + expect(parsed.ok).toBe(true); + expect(parsed.kind).toBe('session_set_permission_mode'); + expect(parsed.data?.sessionId).toBe(sessionId); + expect(parsed.data?.permissionMode).toBe('safe-yolo'); + } finally { + logSpy.mockRestore(); + } + }); +}); + diff --git a/apps/cli/src/cli/commands/session/setPermissionMode.ts b/apps/cli/src/cli/commands/session/setPermissionMode.ts new file mode 100644 index 000000000..43975b2b4 --- /dev/null +++ b/apps/cli/src/cli/commands/session/setPermissionMode.ts @@ -0,0 +1,90 @@ +import chalk from 'chalk'; + +import { parsePermissionIntentAlias, type PermissionIntent } from '@happier-dev/agents'; + +import type { Credentials } from '@/persistence'; +import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; +import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; +import { fetchSessionById } from '@/sessionControl/sessionsHttp'; +import { updateSessionMetadataWithRetry } from '@/sessionControl/updateSessionMetadataWithRetry'; +import { computeNextPermissionIntentMetadata } from '@happier-dev/agents'; + +function parseIntentOrThrow(raw: string): PermissionIntent { + const parsed = parsePermissionIntentAlias(raw); + if (!parsed) { + const err = new Error(`Invalid permission mode: ${raw}`); + (err as any).code = 'invalid_arguments'; + throw err; + } + return parsed; +} + +export async function cmdSessionSetPermissionMode( + argv: string[], + deps: Readonly<{ readCredentialsFn: () => Promise<Credentials | null> }>, +): Promise<void> { + const json = wantsJson(argv); + const idOrPrefix = String(argv[1] ?? '').trim(); + const rawMode = String(argv[2] ?? '').trim(); + if (!idOrPrefix || !rawMode) { + throw new Error('Usage: happier session set-permission-mode <session-id-or-prefix> <mode> [--json]'); + } + + const intent = parseIntentOrThrow(rawMode); + + const credentials = await deps.readCredentialsFn(); + if (!credentials) { + if (json) { + printJsonEnvelope({ ok: false, kind: 'session_set_permission_mode', error: { code: 'not_authenticated' } }); + return; + } + console.error(chalk.red('Error:'), 'Not authenticated. Run "happier auth login" first.'); + process.exit(1); + } + + const resolved = await resolveSessionIdOrPrefix({ credentials, idOrPrefix }); + if (!resolved.ok) { + if (json) { + printJsonEnvelope({ + ok: false, + kind: 'session_set_permission_mode', + error: { code: resolved.code, ...(resolved.candidates ? { candidates: resolved.candidates } : {}) }, + }); + return; + } + throw new Error(resolved.code); + } + const sessionId = resolved.sessionId; + + const rawSession = await fetchSessionById({ token: credentials.token, sessionId }); + if (!rawSession) { + if (json) { + printJsonEnvelope({ ok: false, kind: 'session_set_permission_mode', error: { code: 'session_not_found', sessionId } }); + return; + } + console.error(chalk.red('Error:'), `Session not found: ${sessionId}`); + process.exit(1); + } + + const updatedAt = Date.now(); + await updateSessionMetadataWithRetry({ + token: credentials.token, + credentials, + sessionId, + rawSession, + updater: (metadata) => + computeNextPermissionIntentMetadata({ + metadata, + permissionMode: intent, + permissionModeUpdatedAt: updatedAt, + }), + }); + + if (json) { + printJsonEnvelope({ ok: true, kind: 'session_set_permission_mode', data: { sessionId, permissionMode: intent, updatedAt } }); + return; + } + + console.log(chalk.green('✓'), `permission mode set for ${sessionId}: ${intent}`); +} + diff --git a/apps/cli/src/cli/commands/session/setTitle.integration.test.ts b/apps/cli/src/cli/commands/session/setTitle.integration.test.ts new file mode 100644 index 000000000..3b0bc3f96 --- /dev/null +++ b/apps/cli/src/cli/commands/session/setTitle.integration.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const { mockIo } = vi.hoisted(() => ({ + mockIo: vi.fn(), +})); + +vi.mock('socket.io-client', () => ({ + io: mockIo, +})); + +describe('happier session set-title (integration)', () => { + const originalServerUrl = process.env.HAPPIER_SERVER_URL; + const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; + const originalHomeDir = process.env.HAPPIER_HOME_DIR; + let server: Server | null = null; + let happyHomeDir = ''; + + const sessionId = 'sess_integration_set_title_123'; + + beforeEach(async () => { + happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-set-title-')); + + const secret = new Uint8Array(32).fill(7); + const { encodeBase64, encryptLegacy } = await import('@/api/encryption'); + const metadataCiphertext = encodeBase64( + encryptLegacy({ path: '/tmp', host: 'host1', tag: 'MyTag' }, secret), + 'base64', + ); + + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: metadataCiphertext, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + share: null, + archivedAt: null, + }, + }), + ); + return; + } + res.statusCode = 404; + res.end(); + }); + + await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to resolve integration server address'); + + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; + process.env.HAPPIER_HOME_DIR = happyHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + + mockIo.mockReset(); + mockIo.mockImplementation(() => { + const handlers = new Map<string, Array<(...args: any[]) => void>>(); + const on = vi.fn((event: string, cb: (...args: any[]) => void) => { + const list = handlers.get(event) ?? []; + list.push(cb); + handlers.set(event, list); + }); + const off = vi.fn((event: string, cb: (...args: any[]) => void) => { + const list = handlers.get(event) ?? []; + handlers.set(event, list.filter((v) => v !== cb)); + }); + const connect = vi.fn(() => { + const list = handlers.get('connect') ?? []; + for (const cb of list) cb(); + }); + const emit = vi.fn(async (event: string, ...args: any[]) => { + if (event !== 'update-metadata') return; + const [data, callback] = args; + const { decodeBase64, decryptLegacy } = await import('@/api/encryption'); + const decrypted = decryptLegacy(decodeBase64(String(data?.metadata ?? ''), 'base64'), secret); + expect(decrypted?.summary?.text).toBe('New Title'); + if (typeof callback === 'function') { + callback({ result: 'success', version: 1, metadata: data.metadata }); + } + }); + return { + on, + off, + connect, + emit, + disconnect: vi.fn(), + close: vi.fn(), + }; + }); + }); + + afterEach(async () => { + if (server) { + await new Promise<void>((resolve, reject) => server!.close((e) => (e ? reject(e) : resolve()))); + } + server = null; + if (happyHomeDir) await rm(happyHomeDir, { recursive: true, force: true }); + + if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = originalServerUrl; + if (originalWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = originalWebappUrl; + if (originalHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = originalHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + it('updates session metadata title via update-metadata socket event', async () => { + const { handleSessionCommand } = await import('./index'); + + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => stdout.push(args.join(' '))); + + try { + await handleSessionCommand(['set-title', sessionId, 'New Title', '--json'], { + readCredentialsFn: async () => ({ + token: 'token_test', + encryption: { type: 'legacy', secret: new Uint8Array(32).fill(7) }, + }), + }); + + const parsed = JSON.parse(stdout.join('\n').trim()); + expect(parsed.ok).toBe(true); + expect(parsed.kind).toBe('session_set_title'); + expect(parsed.data?.sessionId).toBe(sessionId); + expect(parsed.data?.title).toBe('New Title'); + } finally { + logSpy.mockRestore(); + } + }); +}); + diff --git a/apps/cli/src/cli/commands/session/setTitle.ts b/apps/cli/src/cli/commands/session/setTitle.ts new file mode 100644 index 000000000..bd26f1ade --- /dev/null +++ b/apps/cli/src/cli/commands/session/setTitle.ts @@ -0,0 +1,67 @@ +import chalk from 'chalk'; + +import type { Credentials } from '@/persistence'; +import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; +import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; +import { fetchSessionById } from '@/sessionControl/sessionsHttp'; +import { updateSessionMetadataWithRetry } from '@/sessionControl/updateSessionMetadataWithRetry'; + +export async function cmdSessionSetTitle( + argv: string[], + deps: Readonly<{ readCredentialsFn: () => Promise<Credentials | null> }>, +): Promise<void> { + const json = wantsJson(argv); + const idOrPrefix = String(argv[1] ?? '').trim(); + const title = String(argv[2] ?? '').trim(); + if (!idOrPrefix || !title) { + throw new Error('Usage: happier session set-title <session-id-or-prefix> <title> [--json]'); + } + + const credentials = await deps.readCredentialsFn(); + if (!credentials) { + if (json) { + printJsonEnvelope({ ok: false, kind: 'session_set_title', error: { code: 'not_authenticated' } }); + return; + } + console.error(chalk.red('Error:'), 'Not authenticated. Run "happier auth login" first.'); + process.exit(1); + } + + const resolved = await resolveSessionIdOrPrefix({ credentials, idOrPrefix }); + if (!resolved.ok) { + if (json) { + printJsonEnvelope({ + ok: false, + kind: 'session_set_title', + error: { code: resolved.code, ...(resolved.candidates ? { candidates: resolved.candidates } : {}) }, + }); + return; + } + throw new Error(resolved.code); + } + const sessionId = resolved.sessionId; + + const rawSession = await fetchSessionById({ token: credentials.token, sessionId }); + if (!rawSession) { + if (json) { + printJsonEnvelope({ ok: false, kind: 'session_set_title', error: { code: 'session_not_found', sessionId } }); + return; + } + console.error(chalk.red('Error:'), `Session not found: ${sessionId}`); + process.exit(1); + } + + await updateSessionMetadataWithRetry({ + token: credentials.token, + credentials, + sessionId, + rawSession, + updater: (metadata) => ({ ...metadata, summary: { text: title, updatedAt: Date.now() } }), + }); + + if (json) { + printJsonEnvelope({ ok: true, kind: 'session_set_title', data: { sessionId, title } }); + return; + } + console.log(chalk.green('✓'), `title set for ${sessionId}`); +} diff --git a/apps/cli/src/cli/commands/session/status.ts b/apps/cli/src/cli/commands/session/status.ts index 46f49356b..1d62c5f64 100644 --- a/apps/cli/src/cli/commands/session/status.ts +++ b/apps/cli/src/cli/commands/session/status.ts @@ -4,7 +4,7 @@ import type { Credentials } from '@/persistence'; import { fetchSessionById } from '@/sessionControl/sessionsHttp'; import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; import { summarizeSessionRecord } from '@/sessionControl/sessionSummary'; -import { resolveSessionEncryptionContextFromCredentials, decryptSessionPayload } from '@/sessionControl/sessionEncryptionContext'; +import { resolveSessionEncryptionContextFromCredentials, decryptSessionPayload, resolveSessionStoredContentEncryptionMode } from '@/sessionControl/sessionEncryptionContext'; import { summarizeAgentState, readLatestAgentStateSummaryViaSocket } from '@/sessionControl/sessionSocketAgentState'; import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; import { hasFlag } from '@/sessionControl/argvFlags'; @@ -56,11 +56,15 @@ export async function cmdSessionStatus( const summary = summarizeSessionRecord({ credentials, session: rawSession }); + const storedMode = resolveSessionStoredContentEncryptionMode(rawSession as any); const agentStateCiphertext = typeof (rawSession as any).agentState === 'string' ? String((rawSession as any).agentState).trim() : ''; let agentStateSummary = (() => { if (!agentStateCiphertext) return null; - const ctx = resolveSessionEncryptionContextFromCredentials(credentials, rawSession); try { + if (storedMode === 'plain') { + return summarizeAgentState(JSON.parse(agentStateCiphertext)); + } + const ctx = resolveSessionEncryptionContextFromCredentials(credentials, rawSession); const decrypted = decryptSessionPayload({ ctx, ciphertextBase64: agentStateCiphertext }); return summarizeAgentState(decrypted); } catch { @@ -72,12 +76,13 @@ export async function cmdSessionStatus( const liveWaitRaw = String(process.env.HAPPIER_SESSION_STATUS_LIVE_WAIT_MS ?? '').trim(); const liveWaitParsed = liveWaitRaw ? Number.parseInt(liveWaitRaw, 10) : NaN; const liveWaitMs = Number.isFinite(liveWaitParsed) && liveWaitParsed > 0 ? Math.min(30_000, liveWaitParsed) : 3_000; - const ctx = resolveSessionEncryptionContextFromCredentials(credentials, rawSession); try { + const ctx = resolveSessionEncryptionContextFromCredentials(credentials, rawSession); const liveSummary = await readLatestAgentStateSummaryViaSocket({ token: credentials.token, sessionId, ctx, + sessionEncryptionMode: storedMode, timeoutMs: liveWaitMs, }); if (liveSummary) { diff --git a/apps/cli/src/cli/commands/session/stop.integration.test.ts b/apps/cli/src/cli/commands/session/stop.integration.test.ts index e552369ff..5ca2b919c 100644 --- a/apps/cli/src/cli/commands/session/stop.integration.test.ts +++ b/apps/cli/src/cli/commands/session/stop.integration.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -17,12 +18,49 @@ describe('happier session stop (integration)', () => { const originalServerUrl = process.env.HAPPIER_SERVER_URL; const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; const originalHomeDir = process.env.HAPPIER_HOME_DIR; + let server: Server | null = null; let happyHomeDir = ''; beforeEach(async () => { happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-session-stop-')); - process.env.HAPPIER_SERVER_URL = 'http://127.0.0.1:12345'; + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + if (req.method === 'GET' && url.pathname.startsWith('/v2/sessions/')) { + const sessionId = url.pathname.slice('/v2/sessions/'.length).trim(); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + session: { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + metadata: 'metadata_ciphertext', + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + share: null, + }, + }), + ); + return; + } + res.statusCode = 404; + res.end(); + }); + + await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to resolve integration server address'); + + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; process.env.HAPPIER_HOME_DIR = happyHomeDir; @@ -33,6 +71,13 @@ describe('happier session stop (integration)', () => { }); afterEach(async () => { + if (server) { + await new Promise<void>((resolve, reject) => { + server!.close((error) => (error ? reject(error) : resolve())); + }); + } + server = null; + if (happyHomeDir) await rm(happyHomeDir, { recursive: true, force: true }); if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; diff --git a/apps/cli/src/cli/commands/session/stop.ts b/apps/cli/src/cli/commands/session/stop.ts index a8b62f771..96d083613 100644 --- a/apps/cli/src/cli/commands/session/stop.ts +++ b/apps/cli/src/cli/commands/session/stop.ts @@ -4,6 +4,8 @@ import type { Credentials } from '@/persistence'; import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; import { createSessionScopedSocket } from '@/api/session/sockets'; +import { resolveSessionControlSocketAckTimeoutMs, resolveSessionControlSocketConnectTimeoutMs } from '@/sessionControl/sessionControlTimeouts'; +import { waitForSocketConnect } from '@/sessionControl/waitForSocketConnect'; export async function cmdSessionStop( argv: string[], @@ -41,24 +43,13 @@ export async function cmdSessionStop( const socket = createSessionScopedSocket({ token: credentials.token, sessionId }); - const timeoutMs = 10_000; - const waitForConnect = new Promise<void>((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Socket connect timeout')), timeoutMs); - socket.on('connect', () => { - clearTimeout(timer); - resolve(); - }); - socket.on('connect_error', (err) => { - clearTimeout(timer); - reject(err instanceof Error ? err : new Error(String(err))); - }); - }); - + const connectTimeoutMs = resolveSessionControlSocketConnectTimeoutMs(); + const connectPromise = waitForSocketConnect(socket as unknown as import('socket.io-client').Socket, connectTimeoutMs); socket.connect(); - await waitForConnect; + await connectPromise; await new Promise<void>((resolve) => { - const timer = setTimeout(() => resolve(), timeoutMs); + const timer = setTimeout(() => resolve(), resolveSessionControlSocketAckTimeoutMs()); // socket.io supports ACK callbacks; our typed socket surface doesn't model it here. (socket as any).emit('session-end', { sid: sessionId, time: Date.now() }, () => { clearTimeout(timer); diff --git a/apps/cli/src/cli/commands/session/unarchive.ts b/apps/cli/src/cli/commands/session/unarchive.ts new file mode 100644 index 000000000..a2a3f4f92 --- /dev/null +++ b/apps/cli/src/cli/commands/session/unarchive.ts @@ -0,0 +1,51 @@ +import chalk from 'chalk'; + +import type { Credentials } from '@/persistence'; +import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; +import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; +import { unarchiveSession } from '@/sessionControl/sessionsHttp'; + +export async function cmdSessionUnarchive( + argv: string[], + deps: Readonly<{ readCredentialsFn: () => Promise<Credentials | null> }>, +): Promise<void> { + const json = wantsJson(argv); + const idOrPrefix = String(argv[1] ?? '').trim(); + if (!idOrPrefix) { + throw new Error('Usage: happier session unarchive <session-id-or-prefix> [--json]'); + } + + const credentials = await deps.readCredentialsFn(); + if (!credentials) { + if (json) { + printJsonEnvelope({ ok: false, kind: 'session_unarchive', error: { code: 'not_authenticated' } }); + return; + } + console.error(chalk.red('Error:'), 'Not authenticated. Run "happier auth login" first.'); + process.exit(1); + } + + const resolved = await resolveSessionIdOrPrefix({ credentials, idOrPrefix }); + if (!resolved.ok) { + if (json) { + printJsonEnvelope({ + ok: false, + kind: 'session_unarchive', + error: { code: resolved.code, ...(resolved.candidates ? { candidates: resolved.candidates } : {}) }, + }); + return; + } + throw new Error(resolved.code); + } + const sessionId = resolved.sessionId; + + await unarchiveSession({ token: credentials.token, sessionId }); + + if (json) { + printJsonEnvelope({ ok: true, kind: 'session_unarchive', data: { sessionId, archivedAt: null } }); + return; + } + + console.log(chalk.green('✓'), `unarchived ${sessionId}`); +} + diff --git a/apps/cli/src/cli/commands/session/voiceAgent/start.feat.voice.agent.integration.test.ts b/apps/cli/src/cli/commands/session/voiceAgent/start.feat.voice.agent.integration.test.ts index 180df79cd..6780ceb9d 100644 --- a/apps/cli/src/cli/commands/session/voiceAgent/start.feat.voice.agent.integration.test.ts +++ b/apps/cli/src/cli/commands/session/voiceAgent/start.feat.voice.agent.integration.test.ts @@ -54,11 +54,19 @@ describe('happier session voice-agent start (integration)', () => { JSON.stringify({ session: { id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, metadata: metadataCiphertext, metadataVersion: 0, agentState: null, agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, dataEncryptionKey: dataEncryptionKeyBase64, + share: null, }, }), ); diff --git a/apps/cli/src/cli/commands/session/wait.ts b/apps/cli/src/cli/commands/session/wait.ts index bf4097117..6c554061c 100644 --- a/apps/cli/src/cli/commands/session/wait.ts +++ b/apps/cli/src/cli/commands/session/wait.ts @@ -5,7 +5,7 @@ import { wantsJson, printJsonEnvelope } from '@/sessionControl/jsonOutput'; import { readIntFlagValue } from '@/sessionControl/argvFlags'; import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; import { fetchSessionById } from '@/sessionControl/sessionsHttp'; -import { resolveSessionEncryptionContextFromCredentials } from '@/sessionControl/sessionEncryptionContext'; +import { resolveSessionEncryptionContextFromCredentials, resolveSessionStoredContentEncryptionMode } from '@/sessionControl/sessionEncryptionContext'; import { waitForIdleViaSocket } from '@/sessionControl/sessionSocketAgentState'; export async function cmdSessionWait( @@ -59,6 +59,7 @@ export async function cmdSessionWait( } const ctx = resolveSessionEncryptionContextFromCredentials(credentials, rawSession); + const storedMode = resolveSessionStoredContentEncryptionMode(rawSession as any); const agentStateCiphertext = typeof (rawSession as any).agentState === 'string' ? String((rawSession as any).agentState).trim() : null; @@ -67,6 +68,7 @@ export async function cmdSessionWait( token: credentials.token, sessionId, ctx, + sessionEncryptionMode: storedMode, timeoutMs: timeoutSeconds * 1000, initialAgentStateCiphertextBase64: agentStateCiphertext && agentStateCiphertext.length > 0 ? agentStateCiphertext : null, }); @@ -84,4 +86,3 @@ export async function cmdSessionWait( throw error; } } - diff --git a/apps/cli/src/cli/runtime/update/autoUpdateNotice.test.ts b/apps/cli/src/cli/runtime/update/autoUpdateNotice.test.ts index 6ffdcca2a..59854fb4d 100644 --- a/apps/cli/src/cli/runtime/update/autoUpdateNotice.test.ts +++ b/apps/cli/src/cli/runtime/update/autoUpdateNotice.test.ts @@ -75,6 +75,31 @@ describe('maybeAutoUpdateNotice', () => { } }); + it('does not crash when spawnDetached throws', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'happy-cli-update-')); + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => + maybeAutoUpdateNotice({ + argv: ['start'], + isTTY: true, + homeDir, + cliRootDir: '/repo/apps/cli', + env: {}, + nowMs: 100_000, + spawnDetached: () => { + throw new Error('boom'); + }, + notifyIntervalMs: 1000, + checkIntervalMs: 0, + }), + ).not.toThrow(); + } finally { + stderr.mockRestore(); + rmSync(homeDir, { recursive: true, force: true }); + } + }); + it('does not spawn background checks for --version invocations', () => { const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); const spawnDetached = vi.fn(); diff --git a/apps/cli/src/cli/runtime/update/autoUpdateNotice.ts b/apps/cli/src/cli/runtime/update/autoUpdateNotice.ts index eb1a65b40..766ca9df3 100644 --- a/apps/cli/src/cli/runtime/update/autoUpdateNotice.ts +++ b/apps/cli/src/cli/runtime/update/autoUpdateNotice.ts @@ -131,10 +131,14 @@ export function maybeAutoUpdateNotice(params: Readonly<{ const lockTtlMs = envNumber(env, 'HAPPIER_CLI_UPDATE_CHECK_LOCK_TTL_MS') ?? DEFAULT_CHECK_LOCK_TTL_MS; const lockPath = join(params.homeDir, 'cache', 'update.check.lock.json'); if (!acquireSingleFlightLock({ lockPath, nowMs: now, ttlMs: lockTtlMs, pid: process.pid })) return; - spawnImpl({ - script: entry, - args: ['self', 'check', '--quiet'], - cwd: params.cliRootDir, - env: { ...env, HAPPIER_CLI_UPDATE_CHECK_SPAWNED: '1' }, - }); + try { + spawnImpl({ + script: entry, + args: ['self', 'check', '--quiet'], + cwd: params.cliRootDir, + env: { ...env, HAPPIER_CLI_UPDATE_CHECK_SPAWNED: '1' }, + }); + } catch { + // Best-effort: update checks must never crash the CLI. + } } diff --git a/apps/cli/src/cli/runtime/update/binarySelfUpdate.test.ts b/apps/cli/src/cli/runtime/update/binarySelfUpdate.test.ts new file mode 100644 index 000000000..64702d298 --- /dev/null +++ b/apps/cli/src/cli/runtime/update/binarySelfUpdate.test.ts @@ -0,0 +1,131 @@ +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createHash, generateKeyPairSync, sign, type KeyObject } from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; + +import { updateBinaryFromReleaseAssets, resolveCliBinaryAssetBundleFromReleaseAssets } from './binarySelfUpdate'; + +function b64(buf: Buffer) { + return Buffer.from(buf).toString('base64'); +} + +function base64UrlToBuffer(value: string) { + const s = String(value ?? '') + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(String(value ?? '').length / 4) * 4, '='); + return Buffer.from(s, 'base64'); +} + +function createMinisignKeyPair(): Readonly<{ pubkeyFile: string; keyId: Buffer; privateKey: KeyObject }> { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const jwk = publicKey.export({ format: 'jwk' }) as { x: string }; + const rawPublicKey = base64UrlToBuffer(jwk.x); + + const keyId = Buffer.from('0123456789abcdef', 'hex'); + const publicKeyBytes = Buffer.concat([Buffer.from('Ed'), keyId, rawPublicKey]); + const pubkeyFile = `untrusted comment: minisign public key\n${b64(publicKeyBytes)}\n`; + return { pubkeyFile, keyId, privateKey }; +} + +function signMinisignMessage(params: Readonly<{ message: Buffer; keyId: Buffer; privateKey: KeyObject }>) { + const signature = sign(null, params.message, params.privateKey); + const sigLineBytes = Buffer.concat([Buffer.from('Ed'), params.keyId, signature]); + const trustedComment = 'trusted comment: test'; + const trustedSuffix = Buffer.from(trustedComment.slice('trusted comment: '.length), 'utf-8'); + const globalSignature = sign(null, Buffer.concat([signature, trustedSuffix]), params.privateKey); + return [ + 'untrusted comment: signature from happier test', + b64(sigLineBytes), + trustedComment, + b64(globalSignature), + '', + ].join('\n'); +} + +function sha256Hex(bytes: Buffer) { + return createHash('sha256').update(bytes).digest('hex'); +} + +describe('binarySelfUpdate', () => { + it('resolves the newest platform tarball from a rolling-tag asset list (last match wins)', () => { + const assets = [ + { name: 'happier-v1.0.0-linux-x64.tar.gz', browser_download_url: 'https://example/old.tgz' }, + { name: 'checksums-happier-v1.0.0.txt', browser_download_url: 'https://example/old-checksums.txt' }, + { name: 'checksums-happier-v1.0.0.txt.minisig', browser_download_url: 'https://example/old-checksums.txt.minisig' }, + { name: 'happier-v1.0.1-linux-x64.tar.gz', browser_download_url: 'https://example/new.tgz' }, + { name: 'checksums-happier-v1.0.1.txt', browser_download_url: 'https://example/new-checksums.txt' }, + { name: 'checksums-happier-v1.0.1.txt.minisig', browser_download_url: 'https://example/new-checksums.txt.minisig' }, + ]; + + const resolved = resolveCliBinaryAssetBundleFromReleaseAssets({ + assets, + os: 'linux', + arch: 'x64', + preferVersion: null, + }); + + expect(resolved.version).toBe('1.0.1'); + expect(resolved.archive.name).toBe('happier-v1.0.1-linux-x64.tar.gz'); + }); + + it('downloads + verifies + replaces the running binary', async () => { + const root = mkdtempSync(join(tmpdir(), 'happier-binary-update-')); + try { + const homeDir = join(root, 'home'); + const scratch = join(root, 'scratch'); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(scratch, { recursive: true }); + + const targetBin = join(root, 'happier'); + writeFileSync(targetBin, 'old\n', 'utf8'); + chmodSync(targetBin, 0o755); + + const version = '9.9.9-preview.2'; + const stem = `happier-v${version}-linux-x64`; + const artifactDir = join(scratch, stem); + mkdirSync(artifactDir, { recursive: true }); + const embeddedBin = join(artifactDir, 'happier'); + writeFileSync(embeddedBin, 'new\n', 'utf8'); + chmodSync(embeddedBin, 0o755); + + const archiveName = `${stem}.tar.gz`; + const archivePath = join(scratch, archiveName); + const tarRes = spawnSync('tar', ['-czf', archivePath, '-C', scratch, stem], { encoding: 'utf8' }); + expect(tarRes.status).toBe(0); + + const archiveBytes = readFileSync(archivePath); + const archiveSha = sha256Hex(archiveBytes); + + const { pubkeyFile, keyId, privateKey } = createMinisignKeyPair(); + const checksumsText = `${archiveSha} ${archiveName}\n`; + const sigFile = signMinisignMessage({ message: Buffer.from(checksumsText, 'utf-8'), keyId, privateKey }); + + const archiveUrl = `data:application/octet-stream;base64,${archiveBytes.toString('base64')}`; + const checksumsUrl = `data:text/plain,${encodeURIComponent(checksumsText)}`; + const sigUrl = `data:text/plain,${encodeURIComponent(sigFile)}`; + + const assets = [ + { name: archiveName, browser_download_url: archiveUrl }, + { name: `checksums-happier-v${version}.txt`, browser_download_url: checksumsUrl }, + { name: `checksums-happier-v${version}.txt.minisig`, browser_download_url: sigUrl }, + ]; + + await updateBinaryFromReleaseAssets({ + assets, + os: 'linux', + arch: 'x64', + execPath: targetBin, + minisignPubkeyFile: pubkeyFile, + preferVersion: null, + }); + + expect(readFileSync(targetBin, 'utf8')).toBe('new\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/cli/runtime/update/binarySelfUpdate.ts b/apps/cli/src/cli/runtime/update/binarySelfUpdate.ts new file mode 100644 index 000000000..c9fe1a452 --- /dev/null +++ b/apps/cli/src/cli/runtime/update/binarySelfUpdate.ts @@ -0,0 +1,204 @@ +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile, lstat, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +import * as tar from 'tar'; +import { DEFAULT_MINISIGN_PUBLIC_KEY } from '@happier-dev/release-runtime/minisign'; +import { downloadVerifiedReleaseAssetBundle } from '@happier-dev/release-runtime/verifiedDownload'; +import { fetchGitHubReleaseByTag } from '@happier-dev/release-runtime/github'; + +type RawAsset = Readonly<{ name?: unknown; browser_download_url?: unknown }>; + +export type ReleaseAsset = Readonly<{ name: string; url: string }>; + +export type ReleaseAssetBundle = Readonly<{ + version: string; + archive: ReleaseAsset; + checksums: ReleaseAsset; + checksumsSig: ReleaseAsset; +}>; + +function normalizeAsset(asset: RawAsset): ReleaseAsset | null { + const name = String(asset?.name ?? '').trim(); + const url = String(asset?.browser_download_url ?? '').trim(); + if (!name || !url) return null; + return { name, url }; +} + +function sha256Hex(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function inferVersionFromArchiveName(params: Readonly<{ archiveName: string; os: string; arch: string }>): string | null { + const suffix = `-${params.os}-${params.arch}.tar.gz`; + const name = String(params.archiveName ?? '').trim(); + if (!name.startsWith('happier-v')) return null; + if (!name.endsWith(suffix)) return null; + const v = name.slice('happier-v'.length, name.length - suffix.length); + return v || null; +} + +export function resolveCliBinaryAssetBundleFromReleaseAssets(params: Readonly<{ + assets: unknown; + os: string; + arch: string; + preferVersion: string | null; +}>): ReleaseAssetBundle { + const os = String(params.os ?? '').trim(); + const arch = String(params.arch ?? '').trim(); + if (!os) throw new Error('os is required'); + if (!arch) throw new Error('arch is required'); + + const preferVersion = String(params.preferVersion ?? '').trim() || null; + const list = Array.isArray(params.assets) ? (params.assets as RawAsset[]) : []; + + const desiredArchiveName = preferVersion ? `happier-v${preferVersion}-${os}-${arch}.tar.gz` : null; + const archiveRe = new RegExp(`^happier-v.+-${os}-${arch}\\.tar\\.gz$`); + + let selectedArchive: ReleaseAsset | null = null; + for (const asset of list) { + const a = normalizeAsset(asset); + if (!a) continue; + if (desiredArchiveName) { + if (a.name === desiredArchiveName) selectedArchive = a; + continue; + } + if (archiveRe.test(a.name)) { + selectedArchive = a; // last match wins (rolling tags) + } + } + if (!selectedArchive) { + throw new Error(`missing CLI archive for ${os}-${arch}`); + } + + const version = inferVersionFromArchiveName({ archiveName: selectedArchive.name, os, arch }); + if (!version) { + throw new Error(`failed to infer version from archive name: ${selectedArchive.name}`); + } + + const checksumsName = `checksums-happier-v${version}.txt`; + const sigName = `${checksumsName}.minisig`; + + let checksums: ReleaseAsset | null = null; + let checksumsSig: ReleaseAsset | null = null; + for (const asset of list) { + const a = normalizeAsset(asset); + if (!a) continue; + if (a.name === checksumsName) checksums = a; + if (a.name === sigName) checksumsSig = a; + } + if (!checksums) throw new Error(`missing release asset: ${checksumsName}`); + if (!checksumsSig) throw new Error(`missing release asset: ${sigName}`); + + return { + version, + archive: selectedArchive, + checksums, + checksumsSig, + }; +} + +async function resolveWritableBinaryTarget(execPath: string): Promise<string> { + const raw = String(execPath ?? '').trim(); + if (!raw) throw new Error('execPath is required'); + const st = await lstat(raw).catch(() => null); + if (!st) throw new Error(`binary path does not exist: ${raw}`); + if (st.isSymbolicLink()) { + const resolved = await realpath(raw); + return resolved; + } + return raw; +} + +async function replaceFileAtomic(params: Readonly<{ targetPath: string; bytes: Buffer; mode: number }>): Promise<void> { + const dir = dirname(params.targetPath); + await mkdir(dir, { recursive: true }); + const tmpPath = join(dir, `.${basename(params.targetPath)}.tmp-${process.pid}-${Date.now()}`); + await writeFile(tmpPath, params.bytes, { mode: params.mode }); + await rename(tmpPath, params.targetPath); +} + +async function extractBinaryFromArchive(params: Readonly<{ archivePath: string; archiveName: string; extractDir: string }>): Promise<string> { + await mkdir(params.extractDir, { recursive: true }); + await tar.x({ + file: params.archivePath, + cwd: params.extractDir, + }); + const stem = params.archiveName.replace(/\.tar\.gz$/i, ''); + const candidate = join(params.extractDir, stem, 'happier'); + const info = await stat(candidate).catch(() => null); + if (!info?.isFile()) { + throw new Error(`extracted binary not found at expected path: ${candidate}`); + } + return candidate; +} + +export async function updateBinaryFromReleaseAssets(params: Readonly<{ + assets: unknown; + os: string; + arch: string; + execPath: string; + minisignPubkeyFile?: string; + preferVersion: string | null; +}>): Promise<Readonly<{ updatedTo: string; updatedPath: string }>> { + const bundle = resolveCliBinaryAssetBundleFromReleaseAssets({ + assets: params.assets, + os: params.os, + arch: params.arch, + preferVersion: params.preferVersion, + }); + + const pubkeyFile = String(params.minisignPubkeyFile ?? '').trim() || DEFAULT_MINISIGN_PUBLIC_KEY; + const scratchRoot = await mkdtemp(join(tmpdir(), 'happier-self-update-')); + try { + const downloadDir = join(scratchRoot, 'download'); + const extractDir = join(scratchRoot, 'extract'); + const downloaded = await downloadVerifiedReleaseAssetBundle({ + bundle, + destDir: downloadDir, + pubkeyFile, + userAgent: 'happier-cli', + }); + + const extractedBinaryPath = await extractBinaryFromArchive({ + archivePath: downloaded.archivePath, + archiveName: downloaded.archiveName, + extractDir, + }); + + const bytes = await readFile(extractedBinaryPath); + const targetPath = await resolveWritableBinaryTarget(params.execPath); + await replaceFileAtomic({ targetPath, bytes, mode: 0o755 }); + return { updatedTo: bundle.version, updatedPath: targetPath }; + } finally { + await rm(scratchRoot, { recursive: true, force: true }); + } +} + +export async function updateCliBinaryFromGitHubTag(params: Readonly<{ + githubRepo: string; + tag: string; + githubToken?: string; + os: string; + arch: string; + execPath: string; + preferVersion: string | null; + minisignPubkeyFile?: string; +}>): Promise<Readonly<{ updatedTo: string; updatedPath: string }>> { + const release = await fetchGitHubReleaseByTag({ + githubRepo: params.githubRepo, + tag: params.tag, + githubToken: String(params.githubToken ?? '').trim(), + userAgent: 'happier-cli', + }); + const assets = typeof release === 'object' && release != null && 'assets' in release ? (release as any).assets : null; + return updateBinaryFromReleaseAssets({ + assets, + os: params.os, + arch: params.arch, + execPath: params.execPath, + preferVersion: params.preferVersion, + minisignPubkeyFile: params.minisignPubkeyFile, + }); +} diff --git a/apps/cli/src/configuration.ts b/apps/cli/src/configuration.ts index 695d9fc7a..fa1421d9f 100644 --- a/apps/cli/src/configuration.ts +++ b/apps/cli/src/configuration.ts @@ -47,6 +47,18 @@ class Configuration { // MCP server SSE keepalive (prevents client idle timeouts on long-lived streams). public readonly mcpSseKeepAliveIntervalMs: number | null + // Transcript lookup / recovery (fallback path when socket ACK/broadcast is missed). + public readonly transcriptLookupRequestTimeoutMs: number + public readonly transcriptLookupPollIntervalMs: number + public readonly transcriptLookupErrorBackoffBaseMs: number + public readonly transcriptLookupErrorBackoffMaxMs: number + public readonly transcriptLookupKeepAliveEnabled: boolean + + public readonly transcriptRecoveryDelayMs: number + public readonly transcriptRecoveryMaxWaitMs: number + public readonly transcriptRecoveryMaxConcurrent: number + public readonly transcriptRecoveryErrorLogThrottleMs: number + // Claude remote TaskOutput sidechain import limits (defense-in-depth against huge transcripts). public readonly claudeTaskOutputMaxPendingPerAgent: number public readonly claudeTaskOutputMaxSeenUuidsPerSidechain: number @@ -142,6 +154,70 @@ class Configuration { this.mcpSseKeepAliveIntervalMs = mcpKeepAliveRaw === '0' ? null : (Number.isFinite(mcpKeepAliveMs) && mcpKeepAliveMs >= 10 ? mcpKeepAliveMs : 15_000); + const transcriptLookupRequestTimeoutRaw = Number.parseInt( + String(process.env.HAPPIER_TRANSCRIPT_LOOKUP_REQUEST_TIMEOUT_MS ?? ''), + 10, + ); + this.transcriptLookupRequestTimeoutMs = + Number.isFinite(transcriptLookupRequestTimeoutRaw) && transcriptLookupRequestTimeoutRaw >= 250 + ? transcriptLookupRequestTimeoutRaw + : 10_000; + + const transcriptLookupPollIntervalRaw = Number.parseInt( + String(process.env.HAPPIER_TRANSCRIPT_LOOKUP_POLL_INTERVAL_MS ?? ''), + 10, + ); + this.transcriptLookupPollIntervalMs = + Number.isFinite(transcriptLookupPollIntervalRaw) && transcriptLookupPollIntervalRaw >= 10 + ? transcriptLookupPollIntervalRaw + : 150; + + const transcriptLookupErrorBackoffBaseRaw = Number.parseInt( + String(process.env.HAPPIER_TRANSCRIPT_LOOKUP_ERROR_BACKOFF_BASE_MS ?? ''), + 10, + ); + this.transcriptLookupErrorBackoffBaseMs = + Number.isFinite(transcriptLookupErrorBackoffBaseRaw) && transcriptLookupErrorBackoffBaseRaw >= 10 + ? transcriptLookupErrorBackoffBaseRaw + : Math.max(50, this.transcriptLookupPollIntervalMs); + + const transcriptLookupErrorBackoffMaxRaw = Number.parseInt( + String(process.env.HAPPIER_TRANSCRIPT_LOOKUP_ERROR_BACKOFF_MAX_MS ?? ''), + 10, + ); + this.transcriptLookupErrorBackoffMaxMs = + Number.isFinite(transcriptLookupErrorBackoffMaxRaw) && transcriptLookupErrorBackoffMaxRaw >= this.transcriptLookupErrorBackoffBaseMs + ? transcriptLookupErrorBackoffMaxRaw + : Math.max(this.transcriptLookupErrorBackoffBaseMs, 2_000); + + const transcriptLookupKeepAliveRaw = String(process.env.HAPPIER_TRANSCRIPT_LOOKUP_KEEPALIVE ?? '').trim().toLowerCase(); + this.transcriptLookupKeepAliveEnabled = + transcriptLookupKeepAliveRaw.length === 0 ? true : ['1', 'true', 'yes', 'on'].includes(transcriptLookupKeepAliveRaw); + + const transcriptRecoveryDelayRaw = Number.parseInt(String(process.env.HAPPIER_TRANSCRIPT_RECOVERY_DELAY_MS ?? ''), 10); + this.transcriptRecoveryDelayMs = + Number.isFinite(transcriptRecoveryDelayRaw) && transcriptRecoveryDelayRaw >= 0 ? transcriptRecoveryDelayRaw : 500; + + const transcriptRecoveryMaxWaitRaw = Number.parseInt(String(process.env.HAPPIER_TRANSCRIPT_RECOVERY_MAX_WAIT_MS ?? ''), 10); + this.transcriptRecoveryMaxWaitMs = + Number.isFinite(transcriptRecoveryMaxWaitRaw) && transcriptRecoveryMaxWaitRaw >= 250 ? transcriptRecoveryMaxWaitRaw : 7_500; + + const transcriptRecoveryMaxConcurrentRaw = Number.parseInt( + String(process.env.HAPPIER_TRANSCRIPT_RECOVERY_MAX_CONCURRENT ?? ''), + 10, + ); + this.transcriptRecoveryMaxConcurrent = + Number.isFinite(transcriptRecoveryMaxConcurrentRaw) && transcriptRecoveryMaxConcurrentRaw >= 1 + ? Math.floor(transcriptRecoveryMaxConcurrentRaw) + : 3; + + const transcriptRecoveryLogThrottleRaw = Number.parseInt( + String(process.env.HAPPIER_TRANSCRIPT_RECOVERY_ERROR_LOG_THROTTLE_MS ?? ''), + 10, + ); + this.transcriptRecoveryErrorLogThrottleMs = + Number.isFinite(transcriptRecoveryLogThrottleRaw) && transcriptRecoveryLogThrottleRaw >= 0 ? transcriptRecoveryLogThrottleRaw : 5_000; + const maxPendingRaw = Number.parseInt(String(process.env.HAPPIER_CLAUDE_TASKOUTPUT_MAX_PENDING_PER_AGENT ?? ''), 10); const maxSeenUuidsRaw = Number.parseInt(String(process.env.HAPPIER_CLAUDE_TASKOUTPUT_MAX_SEEN_UUIDS_PER_SIDECHAIN ?? ''), 10); const maxToolUseRaw = Number.parseInt(String(process.env.HAPPIER_CLAUDE_TASKOUTPUT_MAX_TOOLUSE_ENTRIES ?? ''), 10); diff --git a/apps/cli/src/daemon/automation/automationWorker.feat.automations.integration.test.ts b/apps/cli/src/daemon/automation/automationWorker.feat.automations.integration.test.ts index ebfb57b64..6daecaccc 100644 --- a/apps/cli/src/daemon/automation/automationWorker.feat.automations.integration.test.ts +++ b/apps/cli/src/daemon/automation/automationWorker.feat.automations.integration.test.ts @@ -221,6 +221,7 @@ describe('automationWorker integration', () => { encryption: TEST_ENCRYPTION, spawnSession, env: { + HAPPIER_FEATURE_AUTOMATIONS__ENABLED: '1', HAPPIER_AUTOMATION_CLAIM_POLL_MS: '20', HAPPIER_AUTOMATION_ASSIGNMENT_REFRESH_MS: '20', HAPPIER_AUTOMATION_LEASE_MS: '200', @@ -229,7 +230,13 @@ describe('automationWorker integration', () => { }); try { - await waitForCondition(() => server.state.succeeded.length === 1); + await waitForCondition( + () => server.state.succeeded.length === 1 || server.state.failed.length === 1, + 30_000, + ); + if (server.state.failed.length > 0) { + throw new Error(`Automation run failed: ${JSON.stringify(server.state.failed[0])}`); + } expect(spawnSession).toHaveBeenCalledTimes(1); expect(server.state.started).toHaveLength(1); expect(server.state.failed).toHaveLength(0); diff --git a/apps/cli/src/daemon/memory/getMemoryWindow.test.ts b/apps/cli/src/daemon/memory/getMemoryWindow.test.ts index b541634f7..78be08238 100644 --- a/apps/cli/src/daemon/memory/getMemoryWindow.test.ts +++ b/apps/cli/src/daemon/memory/getMemoryWindow.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Credentials } from '@/persistence'; import { encryptSessionPayload, type SessionEncryptionContext } from '@/sessionControl/sessionEncryptionContext'; +import { makeSessionFixtureRow } from '@/sessionControl/testFixtures'; describe('getMemoryWindow', () => { it('decrypts a bounded transcript range and returns a redacted snippet window', async () => { @@ -27,7 +28,7 @@ describe('getMemoryWindow', () => { seqTo: 2, paddingMessages: 0, deps: { - fetchSessionById: async () => ({ id: 'sess-1' }), + fetchSessionById: async () => makeSessionFixtureRow({ id: 'sess-1', active: true, activeAt: 1, metadata: 'b64' }), fetchEncryptedTranscriptRange: async () => ({ ok: true as const, rows: [ @@ -44,5 +45,42 @@ describe('getMemoryWindow', () => { expect(window.snippets[0]!.text).toContain('memory search'); expect(window.citations[0]!.sessionId).toBe('sess-1'); }); -}); + it('supports plaintext transcript windows (no decrypt)', async () => { + const { getMemoryWindow } = await import('./getMemoryWindow'); + + const key = new Uint8Array(32).fill(7); + const credentials: Credentials = { token: 't', encryption: { type: 'legacy', secret: key } }; + + const window = await getMemoryWindow({ + credentials, + sessionId: 'sess-plain', + seqFrom: 1, + seqTo: 2, + paddingMessages: 0, + deps: { + fetchSessionById: async () => makeSessionFixtureRow({ id: 'sess-plain', active: true, activeAt: 1, metadata: '{}' }), + fetchEncryptedTranscriptRange: async () => ({ + ok: true as const, + rows: [ + { + seq: 1, + createdAt: 1000, + content: { t: 'plain' as const, v: { role: 'user', content: { type: 'text', text: 'hello' } } }, + }, + { + seq: 2, + createdAt: 2000, + content: { t: 'plain' as const, v: { role: 'agent', content: { type: 'text', text: 'world' } } }, + }, + ], + }), + }, + }); + + expect(window.v).toBe(1); + expect(window.snippets.length).toBe(1); + expect(window.snippets[0]!.text).toContain('User: hello'); + expect(window.snippets[0]!.text).toContain('Assistant: world'); + }); +}); diff --git a/apps/cli/src/daemon/platform/tmux/spawnConfig.ts b/apps/cli/src/daemon/platform/tmux/spawnConfig.ts index c52d8f8dd..c00dcd49f 100644 --- a/apps/cli/src/daemon/platform/tmux/spawnConfig.ts +++ b/apps/cli/src/daemon/platform/tmux/spawnConfig.ts @@ -1,4 +1,4 @@ -import { buildHappyCliSubprocessInvocation } from '@/utils/spawnHappyCLI'; +import { buildHappyCliSubprocessLaunchSpec } from '@/utils/spawnHappyCLI'; import type { CatalogAgentId } from '@/backends/types'; export function buildTmuxWindowEnv( @@ -49,10 +49,10 @@ export function buildTmuxSpawnConfig(params: { ...(params.extraArgs ?? []), ]; - const { runtime, argv, env } = buildHappyCliSubprocessInvocation(args); - const commandTokens = [runtime, ...argv]; + const launchSpec = buildHappyCliSubprocessLaunchSpec(args); + const commandTokens = [launchSpec.filePath, ...launchSpec.args]; - const tmuxEnv = buildTmuxWindowEnv(process.env, { ...params.extraEnv, ...(env ?? {}) }); + const tmuxEnv = buildTmuxWindowEnv(process.env, { ...params.extraEnv, ...(launchSpec.env ?? {}) }); const tmuxCommandEnv: Record<string, string> = { ...(params.tmuxCommandEnv ?? {}) }; const tmuxTmpDir = tmuxCommandEnv.TMUX_TMPDIR; diff --git a/apps/cli/src/daemon/service/darwin.ts b/apps/cli/src/daemon/service/darwin.ts index 735277252..4b711b741 100644 --- a/apps/cli/src/daemon/service/darwin.ts +++ b/apps/cli/src/daemon/service/darwin.ts @@ -1,4 +1,4 @@ -import { dirname } from 'node:path'; +import { buildServicePath } from './servicePath'; function xmlEscape(s: string): string { return String(s ?? '') @@ -9,29 +9,10 @@ function xmlEscape(s: string): string { .replaceAll("'", '''); } -function splitPath(p: string): string[] { - return String(p ?? '') - .split(':') - .map((s) => s.trim()) - .filter(Boolean); -} +const MACOS_DEFAULT_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; export function buildLaunchdPath(params: Readonly<{ execPath?: string; basePath?: string }> = {}): string { - const execPath = params.execPath ?? process.execPath; - const basePath = params.basePath ?? process.env.PATH ?? ''; - const nodeDir = execPath ? dirname(execPath) : ''; - const defaults = splitPath('/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'); - const fromNode = nodeDir ? [nodeDir] : []; - const fromEnv = splitPath(basePath); - - const seen = new Set<string>(); - const out: string[] = []; - for (const part of [...fromNode, ...fromEnv, ...defaults]) { - if (seen.has(part)) continue; - seen.add(part); - out.push(part); - } - return out.join(':') || '/usr/bin:/bin:/usr/sbin:/sbin'; + return buildServicePath({ ...params, defaultPath: MACOS_DEFAULT_PATH }); } export function buildLaunchAgentPlistXml(params: Readonly<{ diff --git a/apps/cli/src/daemon/service/plan.ts b/apps/cli/src/daemon/service/plan.ts index 150944486..2ee2c3d83 100644 --- a/apps/cli/src/daemon/service/plan.ts +++ b/apps/cli/src/daemon/service/plan.ts @@ -1,7 +1,7 @@ import { join, win32 as win32Path } from 'node:path'; import { buildLaunchAgentPlistXml, buildLaunchdPath } from './darwin'; -import { buildSystemdUserUnit, escapeSystemdValue } from './systemdUser'; +import { buildSystemdPath, buildSystemdUserUnit, escapeSystemdValue } from './systemdUser'; import { planServiceAction, renderWindowsScheduledTaskWrapperPs1 } from '@happier-dev/cli-common/service'; export type DaemonServicePlatform = 'darwin' | 'linux' | 'win32'; @@ -209,6 +209,7 @@ export function planDaemonServiceInstall(params: Readonly<{ execStart: programArgs.map(escapeSystemdValue).join(' '), workingDirectory: '%h', env: { + PATH: buildSystemdPath({ execPath: params.nodePath }), HAPPIER_HOME_DIR: params.happierHomeDir, HAPPIER_SERVER_URL: params.serverUrl, HAPPIER_WEBAPP_URL: params.webappUrl, diff --git a/apps/cli/src/daemon/service/servicePath.ts b/apps/cli/src/daemon/service/servicePath.ts new file mode 100644 index 000000000..ac931a1a0 --- /dev/null +++ b/apps/cli/src/daemon/service/servicePath.ts @@ -0,0 +1,45 @@ +/** + * Shared PATH construction utility for daemon service installers. + * + * Provides buildServicePath() to merge the node binary directory, + * the caller's current PATH, and platform-specific defaults with + * deduplication and order preservation. + */ + +import { dirname } from 'node:path'; + +const FALLBACK_PATH = '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; + +function splitPath(p: string): string[] { + return String(p ?? '') + .split(':') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Builds a PATH string for a daemon service by merging the node binary directory, + * the caller's current PATH, and platform-appropriate defaults. Deduplicates entries + * while preserving order (node dir first, then user PATH, then defaults). + */ +export function buildServicePath(params: Readonly<{ + execPath?: string; + basePath?: string; + defaultPath?: string; +}> = {}): string { + const execPath = params.execPath ?? process.execPath; + const basePath = params.basePath ?? process.env.PATH ?? ''; + const nodeDir = execPath ? dirname(execPath) : ''; + const defaults = splitPath(params.defaultPath ?? FALLBACK_PATH); + const fromNode = nodeDir ? [nodeDir] : []; + const fromEnv = splitPath(basePath); + + const seen = new Set<string>(); + const out: string[] = []; + for (const part of [...fromNode, ...fromEnv, ...defaults]) { + if (seen.has(part)) continue; + seen.add(part); + out.push(part); + } + return out.join(':') || FALLBACK_PATH; +} diff --git a/apps/cli/src/daemon/service/systemdUser.ts b/apps/cli/src/daemon/service/systemdUser.ts index 1157d69bb..2ac6d6c3c 100644 --- a/apps/cli/src/daemon/service/systemdUser.ts +++ b/apps/cli/src/daemon/service/systemdUser.ts @@ -1,3 +1,11 @@ +import { buildServicePath } from './servicePath'; + +const LINUX_DEFAULT_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; + +export function buildSystemdPath(params: Readonly<{ execPath?: string; basePath?: string }> = {}): string { + return buildServicePath({ ...params, defaultPath: LINUX_DEFAULT_PATH }); +} + export function escapeSystemdValue(value: string): string { const s = String(value ?? ''); diff --git a/apps/cli/src/daemon/spawn/buildSpawnChildProcessEnv.test.ts b/apps/cli/src/daemon/spawn/buildSpawnChildProcessEnv.test.ts new file mode 100644 index 000000000..8c78d44c4 --- /dev/null +++ b/apps/cli/src/daemon/spawn/buildSpawnChildProcessEnv.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSpawnChildProcessEnv } from './buildSpawnChildProcessEnv'; + +describe('buildSpawnChildProcessEnv', () => { + it('merges process env with extra env and strips nested Claude Code variables', () => { + const env = buildSpawnChildProcessEnv({ + processEnv: { PATH: '/bin', CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'parent' }, + extraEnv: { CUSTOM: 'x' }, + }); + + expect(env.PATH).toBe('/bin'); + expect(env.CUSTOM).toBe('x'); + expect(env.CLAUDECODE).toBeUndefined(); + expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + }); +}); + diff --git a/apps/cli/src/daemon/spawn/buildSpawnChildProcessEnv.ts b/apps/cli/src/daemon/spawn/buildSpawnChildProcessEnv.ts new file mode 100644 index 000000000..25ec237a6 --- /dev/null +++ b/apps/cli/src/daemon/spawn/buildSpawnChildProcessEnv.ts @@ -0,0 +1,8 @@ +import { stripNestedSessionDetectionEnv } from '@/utils/processEnv/stripNestedSessionDetectionEnv'; + +export function buildSpawnChildProcessEnv(params: { + processEnv: NodeJS.ProcessEnv; + extraEnv: Record<string, string | undefined>; +}): NodeJS.ProcessEnv { + return stripNestedSessionDetectionEnv({ ...params.processEnv, ...params.extraEnv }); +} diff --git a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts index 1b0e0db2f..5efdb753e 100644 --- a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts +++ b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts @@ -112,6 +112,7 @@ vi.mock('@/ui/doctor', () => ({ vi.mock('@/utils/spawnHappyCLI', () => ({ buildHappyCliSubprocessInvocation: vi.fn(), + buildHappyCliSubprocessLaunchSpec: vi.fn(), spawnHappyCLI: vi.fn(), })); diff --git a/apps/cli/src/daemon/startDaemon.tmuxSpawn.integration.test.ts b/apps/cli/src/daemon/startDaemon.tmuxSpawn.integration.test.ts index d67a5358c..dd190f877 100644 --- a/apps/cli/src/daemon/startDaemon.tmuxSpawn.integration.test.ts +++ b/apps/cli/src/daemon/startDaemon.tmuxSpawn.integration.test.ts @@ -2,17 +2,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/utils/spawnHappyCLI', () => { return { - buildHappyCliSubprocessInvocation: vi.fn((args: string[]) => { + buildHappyCliSubprocessLaunchSpec: vi.fn((args: string[]) => { const runtime = process.env.HAPPIER_CLI_SUBPROCESS_RUNTIME === 'bun' ? 'bun' : 'node'; if (runtime === 'bun') { return { runtime, - argv: ['/virtual/dist/index.mjs', ...args], + filePath: 'bun', + args: ['/virtual/dist/index.mjs', ...args], }; } return { runtime, - argv: ['--no-warnings', '--no-deprecation', '/virtual/dist/index.mjs', ...args], + filePath: 'node', + args: ['--no-warnings', '--no-deprecation', '/virtual/dist/index.mjs', ...args], }; }), }; diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index 7e84cdef0..0355ed39c 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -16,7 +16,7 @@ import { configuration } from '@/configuration'; import { startCaffeinate, stopCaffeinate } from '@/integrations/caffeinate'; import packageJson from '../../package.json'; import { getEnvironmentInfo } from '@/ui/doctor'; -import { buildHappyCliSubprocessInvocation, spawnHappyCLI } from '@/utils/spawnHappyCLI'; +import { buildHappyCliSubprocessLaunchSpec, spawnHappyCLI } from '@/utils/spawnHappyCLI'; import { AGENTS, getVendorResumeSupport, resolveAgentCliSubcommand, resolveCatalogAgentId } from '@/backends/catalog'; import { writeDaemonState, @@ -62,6 +62,7 @@ import { ensureSessionDirectory } from './startup/ensureSessionDirectory'; import { waitForInitialCredentials } from './startup/waitForInitialCredentials'; import { waitForSessionWebhook } from './spawn/waitForSessionWebhook'; import { resolveSpawnChildEnvironment } from './spawn/resolveSpawnChildEnvironment'; +import { buildSpawnChildProcessEnv } from './spawn/buildSpawnChildProcessEnv'; import { createSpawnConcurrencyGate } from './spawn/createSpawnConcurrencyGate'; import { startAutomationWorker, type AutomationWorkerHandle } from './automation/automationWorker'; import { startMemoryWorker, type MemoryWorkerHandle } from './memory/memoryWorker'; @@ -642,20 +643,18 @@ export async function startDaemon(): Promise<void> { env: process.env, }); - if (windowsConsoleMode === 'visible') { - const { runtime, argv, env } = buildHappyCliSubprocessInvocation(args); - const filePath = runtime === 'node' ? process.execPath : runtime; - - const started = await startHappySessionInVisibleWindowsConsole({ - filePath, - args: argv, - workingDirectory: directory, - env: { - ...process.env, - ...extraEnvForChildWithMessage, - ...(env ?? {}), - }, - }); + if (windowsConsoleMode === 'visible') { + const launchSpec = buildHappyCliSubprocessLaunchSpec(args); + const started = await startHappySessionInVisibleWindowsConsole({ + filePath: launchSpec.filePath, + args: launchSpec.args, + workingDirectory: directory, + env: { + ...process.env, + ...extraEnvForChildWithMessage, + ...(launchSpec.env ?? {}), + }, + }); if (!started.ok) { logger.debug('[DAEMON RUN] Failed to spawn visible Windows console session', { error: started.errorMessage }); @@ -750,14 +749,15 @@ export async function startDaemon(): Promise<void> { } // NOTE: sessionId is reserved for future Happy session resume; we currently ignore it. - const happyProcess = spawnHappyCLI(args, { + const happyProcess = spawnHappyCLI(args, { cwd: directory, detached: true, // Sessions stay alive when daemon stops stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr for debugging - env: { - ...process.env, - ...extraEnvForChildWithMessage - } + windowsHide: true, + env: buildSpawnChildProcessEnv({ + processEnv: process.env, + extraEnv: extraEnvForChildWithMessage, + }) }); // Log output for debugging diff --git a/apps/cli/src/integrations/difftastic/index.ts b/apps/cli/src/integrations/difftastic/index.ts index d716038c7..af4937d1c 100644 --- a/apps/cli/src/integrations/difftastic/index.ts +++ b/apps/cli/src/integrations/difftastic/index.ts @@ -43,7 +43,8 @@ export function run(args: string[], options?: DifftasticOptions): Promise<Diffta ...process.env, // Force color output when needed FORCE_COLOR: '1' - } + }, + windowsHide: true, }); let stdout = ''; diff --git a/apps/cli/src/integrations/ripgrep/index.ts b/apps/cli/src/integrations/ripgrep/index.ts index 41b5be7c3..80d84eaa0 100644 --- a/apps/cli/src/integrations/ripgrep/index.ts +++ b/apps/cli/src/integrations/ripgrep/index.ts @@ -27,7 +27,8 @@ export function run(args: string[], options?: RipgrepOptions): Promise<RipgrepRe return new Promise((resolve, reject) => { const child = spawn('node', [RUNNER_PATH, JSON.stringify(args)], { stdio: ['pipe', 'pipe', 'pipe'], - cwd: options?.cwd + cwd: options?.cwd, + windowsHide: true, }); let stdout = ''; diff --git a/apps/cli/src/rpc/handlers/bash.ts b/apps/cli/src/rpc/handlers/bash.ts index b450740b8..55db70374 100644 --- a/apps/cli/src/rpc/handlers/bash.ts +++ b/apps/cli/src/rpc/handlers/bash.ts @@ -49,6 +49,7 @@ export function registerBashHandler(rpcHandlerManager: RpcHandlerRegistrar, work const options: ExecOptions = { cwd, timeout: data.timeout || 30000, // Default 30 seconds timeout + windowsHide: true, }; logger.debug('Shell command executing...', { cwd: options.cwd, timeout: options.timeout }); diff --git a/apps/cli/src/rpc/handlers/registerSessionHandlers.capabilities.integration.test.ts b/apps/cli/src/rpc/handlers/registerSessionHandlers.capabilities.integration.test.ts index 59be796d3..c20c35ef5 100644 --- a/apps/cli/src/rpc/handlers/registerSessionHandlers.capabilities.integration.test.ts +++ b/apps/cli/src/rpc/handlers/registerSessionHandlers.capabilities.integration.test.ts @@ -19,6 +19,7 @@ import type { CapabilitiesInvokeResponse, } from '@happier-dev/protocol'; import { CHECKLIST_IDS, resumeChecklistId } from '@happier-dev/protocol/checklists'; +import { CODEX_MCP_RESUME_DEP_ID } from '@happier-dev/protocol/installables'; import { createEncryptedRpcTestClient } from './encryptedRpc.testkit'; function createTestRpcManager(params?: { scopePrefix?: string }) { @@ -69,7 +70,7 @@ describe('registerCommonHandlers capabilities', () => { expect(result.protocolVersion).toBe(1); expect(result.capabilities.map((c) => c.id)).toEqual( - expect.arrayContaining(['cli.codex', 'cli.claude', 'cli.gemini', 'cli.opencode', 'tool.tmux', 'dep.codex-mcp-resume']), + expect.arrayContaining(['cli.codex', 'cli.claude', 'cli.gemini', 'cli.opencode', 'tool.tmux', CODEX_MCP_RESUME_DEP_ID]), ); expect(Object.keys(result.checklists)).toEqual( expect.arrayContaining([ @@ -82,7 +83,7 @@ describe('registerCommonHandlers capabilities', () => { ]), ); expect(result.checklists[resumeChecklistId('codex')].map((r) => r.id)).toEqual( - expect.arrayContaining(['cli.codex', 'dep.codex-mcp-resume']), + expect.arrayContaining(['cli.codex', CODEX_MCP_RESUME_DEP_ID]), ); }); @@ -295,14 +296,14 @@ describe('registerCommonHandlers capabilities', () => { const result = await call<CapabilitiesDetectResponse, CapabilitiesDetectRequest>(RPC_METHODS.CAPABILITIES_DETECT, { requests: [ { id: 'cli.codex', params: { includeLoginStatus: true } }, - { id: 'dep.codex-mcp-resume', params: { includeRegistry: true, onlyIfInstalled: true } }, + { id: CODEX_MCP_RESUME_DEP_ID, params: { includeRegistry: true, onlyIfInstalled: true } }, ], }); const codexData = expectCapabilityData(result, 'cli.codex'); expect(codexData.isLoggedIn).toBe(true); - const resumeData = expectCapabilityData(result, 'dep.codex-mcp-resume'); + const resumeData = expectCapabilityData(result, CODEX_MCP_RESUME_DEP_ID); expect(resumeData.installed).toBe(false); expect(resumeData.registry).toBeUndefined(); } finally { diff --git a/apps/cli/src/session/replay/decryptTranscriptRows.test.ts b/apps/cli/src/session/replay/decryptTranscriptRows.test.ts index 56ad5c278..374bdd7cd 100644 --- a/apps/cli/src/session/replay/decryptTranscriptRows.test.ts +++ b/apps/cli/src/session/replay/decryptTranscriptRows.test.ts @@ -5,6 +5,33 @@ import { encryptSessionPayload, type SessionEncryptionContext } from '@/sessionC import { decryptTranscriptRows } from './decryptTranscriptRows'; describe('decryptTranscriptRows', () => { + it('accepts plaintext transcript rows (no decrypt)', () => { + const ctx: SessionEncryptionContext = { + encryptionVariant: 'legacy', + encryptionKey: new Uint8Array(32).fill(7), + }; + + const rows = decryptTranscriptRows({ + ctx, + rows: [ + { + seq: 1, + createdAt: 1000, + content: { t: 'plain', v: { role: 'user', content: { type: 'text', text: 'hello' } } }, + }, + ], + }); + + expect(rows).toEqual([ + { + seq: 1, + createdAtMs: 1000, + role: 'user', + content: { type: 'text', text: 'hello' }, + }, + ]); + }); + it('preserves seq and structured meta payloads', () => { const ctx: SessionEncryptionContext = { encryptionVariant: 'legacy', @@ -49,4 +76,3 @@ describe('decryptTranscriptRows', () => { expect(rows).toEqual([]); }); }); - diff --git a/apps/cli/src/session/replay/decryptTranscriptRows.ts b/apps/cli/src/session/replay/decryptTranscriptRows.ts index 3bbf5d807..b3c59906c 100644 --- a/apps/cli/src/session/replay/decryptTranscriptRows.ts +++ b/apps/cli/src/session/replay/decryptTranscriptRows.ts @@ -26,12 +26,19 @@ export function decryptTranscriptRows(params: Readonly<{ const createdAtMs = typeof row?.createdAt === 'number' && Number.isFinite(row.createdAt) ? Math.trunc(row.createdAt) : null; const content = row?.content as any; - const ciphertextBase64 = content && typeof content === 'object' && content.t === 'encrypted' ? content.c : null; if (seq === null || createdAtMs === null) continue; - if (typeof ciphertextBase64 !== 'string' || ciphertextBase64.trim().length === 0) continue; try { - const decrypted = decryptSessionPayload({ ctx: params.ctx, ciphertextBase64 }) as any; + let decrypted: any = null; + if (content && typeof content === 'object' && content.t === 'plain') { + decrypted = content.v; + } else { + const ciphertextBase64 = + content && typeof content === 'object' && content.t === 'encrypted' ? content.c : null; + if (typeof ciphertextBase64 !== 'string' || ciphertextBase64.trim().length === 0) continue; + decrypted = decryptSessionPayload({ ctx: params.ctx, ciphertextBase64 }) as any; + } + const role = decrypted?.role; if (role !== 'user' && role !== 'agent') continue; const body = decrypted?.content; @@ -51,4 +58,3 @@ export function decryptTranscriptRows(params: Readonly<{ return out; } - diff --git a/apps/cli/src/session/replay/decryptTranscriptTextItems.test.ts b/apps/cli/src/session/replay/decryptTranscriptTextItems.test.ts index 9f58e629c..ed33b1565 100644 --- a/apps/cli/src/session/replay/decryptTranscriptTextItems.test.ts +++ b/apps/cli/src/session/replay/decryptTranscriptTextItems.test.ts @@ -18,6 +18,20 @@ function encryptedRow(params: { seq: number; createdAt: number; value: unknown } } describe('decryptTranscriptTextItems', () => { + it('accepts plaintext transcript rows without encryption materials (no decrypt)', () => { + const out = decryptTranscriptTextItems({ + rows: [ + { + seq: 1, + createdAt: 1, + content: { t: 'plain', v: { role: 'user', content: { type: 'text', text: 'aaa' } } }, + }, + ], + }); + + expect(out).toEqual([{ role: 'User', createdAt: 1, text: 'aaa' }]); + }); + it('sorts by seq when available (not createdAt)', () => { const a = encryptedRow({ seq: 2, diff --git a/apps/cli/src/session/replay/decryptTranscriptTextItems.ts b/apps/cli/src/session/replay/decryptTranscriptTextItems.ts index 436677f3a..730948962 100644 --- a/apps/cli/src/session/replay/decryptTranscriptTextItems.ts +++ b/apps/cli/src/session/replay/decryptTranscriptTextItems.ts @@ -36,8 +36,8 @@ function truncateText(text: string, maxChars: number): string { export function decryptTranscriptTextItems(params: Readonly<{ rows: readonly RawTranscriptRow[]; - encryptionKey: Uint8Array; - encryptionVariant: 'dataKey'; + encryptionKey?: Uint8Array; + encryptionVariant?: 'dataKey'; maxTextChars?: number; }>): HappierReplayDialogItem[] { const maxTextChars = params.maxTextChars; @@ -48,9 +48,16 @@ export function decryptTranscriptTextItems(params: Readonly<{ typeof (row as any)?.seq === 'number' && Number.isFinite((row as any).seq) ? Number((row as any).seq) : null; const createdAt = typeof row?.createdAt === 'number' && Number.isFinite(row.createdAt) ? row.createdAt : 0; const content = row?.content as any; - if (!content || content.t !== 'encrypted' || typeof content.c !== 'string') continue; + if (!content || typeof content !== 'object') continue; - const decrypted: any = decrypt(params.encryptionKey, params.encryptionVariant, decodeBase64(content.c)); + let decrypted: any = null; + if (content.t === 'plain') { + decrypted = content.v; + } else { + if (content.t !== 'encrypted' || typeof content.c !== 'string') continue; + if (!params.encryptionKey || params.encryptionVariant !== 'dataKey') continue; + decrypted = decrypt(params.encryptionKey, params.encryptionVariant, decodeBase64(content.c)); + } if (!decrypted || typeof decrypted !== 'object') continue; const role = decrypted.role; diff --git a/apps/cli/src/session/replay/hydrateReplayDialogFromTranscript.integration.test.ts b/apps/cli/src/session/replay/hydrateReplayDialogFromTranscript.integration.test.ts new file mode 100644 index 000000000..168cd3a68 --- /dev/null +++ b/apps/cli/src/session/replay/hydrateReplayDialogFromTranscript.integration.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { deriveBoxPublicKeyFromSeed, sealEncryptedDataKeyEnvelopeV1 } from '@happier-dev/protocol'; + +describe('hydrateReplayDialogFromTranscript (integration)', () => { + const originalServerUrl = process.env.HAPPIER_SERVER_URL; + const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; + const originalHomeDir = process.env.HAPPIER_HOME_DIR; + let server: Server | null = null; + let happyHomeDir = ''; + + beforeEach(async () => { + happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-cli-replay-hydrate-')); + }); + + afterEach(async () => { + if (server) { + await new Promise<void>((resolve, reject) => { + server!.close((error) => (error ? reject(error) : resolve())); + }); + } + server = null; + if (happyHomeDir) { + await rm(happyHomeDir, { recursive: true, force: true }); + } + + if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = originalServerUrl; + if (originalWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = originalWebappUrl; + if (originalHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = originalHomeDir; + + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + }); + + it('hydrates plaintext sessions without session encryption materials', async () => { + const sessionId = 'sess_plain_1'; + + const sessionRow = { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + archivedAt: null, + encryptionMode: 'plain', + metadata: JSON.stringify({ flavor: 'claude', path: '/tmp' }), + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + share: null, + }; + + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ session: sessionRow })); + return; + } + + if (req.method === 'GET' && url.pathname === `/v1/sessions/${sessionId}/messages`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + messages: [ + { + seq: 1, + createdAt: 1000, + content: { t: 'plain', v: { role: 'user', content: { type: 'text', text: 'hello' } } }, + }, + ], + }), + ); + return; + } + + res.statusCode = 404; + res.end(); + }); + + await new Promise<void>((resolve) => { + server!.listen(0, '127.0.0.1', () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to resolve replay hydrate server address'); + + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; + process.env.HAPPIER_HOME_DIR = happyHomeDir; + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + + const { hydrateReplayDialogFromTranscript } = await import('./hydrateReplayDialogFromTranscript'); + + const res = await hydrateReplayDialogFromTranscript({ + credentials: { token: 't', encryption: { type: 'legacy', secret: new Uint8Array(32).fill(1) } }, + previousSessionId: sessionId, + limit: 50, + }); + + expect(res).not.toBeNull(); + expect(res?.dialog?.[0]?.text).toBe('hello'); + }); + + it('hydrates encrypted sessions using the published session dataEncryptionKey', async () => { + const sessionId = 'sess_enc_1'; + + const dek = new Uint8Array(32).fill(3); + const machineKeySeed = new Uint8Array(32).fill(8); + const recipientPublicKey = deriveBoxPublicKeyFromSeed(machineKeySeed); + const envelope = sealEncryptedDataKeyEnvelopeV1({ + dataKey: dek, + recipientPublicKey, + randomBytes: (length) => new Uint8Array(length).fill(5), + }); + + const { encodeBase64, encryptWithDataKey } = await import('@/api/encryption'); + const dataEncryptionKeyBase64 = encodeBase64(envelope, 'base64'); + const msgCiphertext = encodeBase64( + encryptWithDataKey({ role: 'user', content: { type: 'text', text: 'hello' } }, dek), + 'base64', + ); + + const sessionRow = { + id: sessionId, + seq: 1, + createdAt: 1, + updatedAt: 2, + active: false, + activeAt: 0, + archivedAt: null, + metadata: 'b64', + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: dataEncryptionKeyBase64, + share: null, + }; + + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === `/v2/sessions/${sessionId}`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ session: sessionRow })); + return; + } + + if (req.method === 'GET' && url.pathname === `/v1/sessions/${sessionId}/messages`) { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + messages: [ + { + seq: 1, + createdAt: 1000, + content: { t: 'encrypted', c: msgCiphertext }, + }, + ], + }), + ); + return; + } + + res.statusCode = 404; + res.end(); + }); + + await new Promise<void>((resolve) => { + server!.listen(0, '127.0.0.1', () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to resolve replay hydrate server address'); + + process.env.HAPPIER_SERVER_URL = `http://127.0.0.1:${address.port}`; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; + process.env.HAPPIER_HOME_DIR = happyHomeDir; + const { reloadConfiguration } = await import('@/configuration'); + reloadConfiguration(); + + const { hydrateReplayDialogFromTranscript } = await import('./hydrateReplayDialogFromTranscript'); + + const res = await hydrateReplayDialogFromTranscript({ + credentials: { + token: 't', + encryption: { + type: 'dataKey', + publicKey: deriveBoxPublicKeyFromSeed(machineKeySeed), + machineKey: machineKeySeed, + }, + }, + previousSessionId: sessionId, + limit: 50, + }); + + expect(res).not.toBeNull(); + expect(res?.dialog?.[0]?.text).toBe('hello'); + }); +}); + diff --git a/apps/cli/src/session/replay/hydrateReplayDialogFromTranscript.ts b/apps/cli/src/session/replay/hydrateReplayDialogFromTranscript.ts index 142e539db..c0d47eb3a 100644 --- a/apps/cli/src/session/replay/hydrateReplayDialogFromTranscript.ts +++ b/apps/cli/src/session/replay/hydrateReplayDialogFromTranscript.ts @@ -1,9 +1,9 @@ import type { Credentials } from '@/persistence'; import { openSessionDataEncryptionKey } from '@/api/client/openSessionDataEncryptionKey'; +import { fetchSessionById } from '@/sessionControl/sessionsHttp'; import { fetchEncryptedTranscriptMessages } from './fetchEncryptedTranscriptMessages'; -import { fetchSessionDataEncryptionKey } from './fetchSessionDataEncryptionKey'; import { decryptTranscriptTextItems } from './decryptTranscriptTextItems'; import type { HappierReplayDialogItem } from './types'; @@ -13,14 +13,28 @@ export async function hydrateReplayDialogFromTranscript(params: Readonly<{ limit: number; maxTextChars?: number; }>): Promise<{ dialog: HappierReplayDialogItem[] } | null> { - if (params.credentials.encryption.type !== 'dataKey') { - return null; - } + const session = await fetchSessionById({ token: params.credentials.token, sessionId: params.previousSessionId }); + if (!session) return null; - const encryptedDekBase64 = await fetchSessionDataEncryptionKey({ + const rows = await fetchEncryptedTranscriptMessages({ token: params.credentials.token, sessionId: params.previousSessionId, + limit: params.limit, }); + + const encryptionMode = (session as any)?.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + if (encryptionMode === 'plain') { + const dialog = decryptTranscriptTextItems({ rows, maxTextChars: params.maxTextChars }); + return { dialog }; + } + + if (params.credentials.encryption.type !== 'dataKey') { + return null; + } + + const encryptedDekBase64 = typeof (session as any)?.dataEncryptionKey === 'string' + ? String((session as any).dataEncryptionKey).trim() + : null; if (!encryptedDekBase64) return null; const dek = openSessionDataEncryptionKey({ @@ -29,12 +43,6 @@ export async function hydrateReplayDialogFromTranscript(params: Readonly<{ }); if (!dek) return null; - const rows = await fetchEncryptedTranscriptMessages({ - token: params.credentials.token, - sessionId: params.previousSessionId, - limit: params.limit, - }); - const dialog = decryptTranscriptTextItems({ rows, encryptionKey: dek, diff --git a/apps/cli/src/sessionControl/resolveSessionId.longPrefix.test.ts b/apps/cli/src/sessionControl/resolveSessionId.longPrefix.test.ts index 83e99e9c5..f299d8275 100644 --- a/apps/cli/src/sessionControl/resolveSessionId.longPrefix.test.ts +++ b/apps/cli/src/sessionControl/resolveSessionId.longPrefix.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; +import { makeSessionFixtureRow } from './testFixtures'; + const { mockAxiosGet } = vi.hoisted(() => ({ mockAxiosGet: vi.fn(), })); @@ -28,11 +30,22 @@ describe('resolveSessionIdOrPrefix', () => { if (url.includes('/v2/sessions/sess_integration')) { return { status: 404, data: {}, headers: {} }; } + if (url.includes('/v2/sessions/archived')) { + return { + status: 200, + data: { + sessions: [], + nextCursor: null, + hasNext: false, + }, + headers: {}, + }; + } if (url.includes('/v2/sessions')) { return { status: 200, data: { - sessions: [{ id: 'sess_integration_run_start_123' }], + sessions: [makeSessionFixtureRow({ id: 'sess_integration_run_start_123' })], nextCursor: null, hasNext: false, }, @@ -61,5 +74,63 @@ describe('resolveSessionIdOrPrefix', () => { reloadConfiguration(); } }); -}); + it('includes archived sessions when resolving by prefix', async () => { + const { reloadConfiguration } = await import('@/configuration'); + const originalServerUrl = process.env.HAPPIER_SERVER_URL; + const originalWebappUrl = process.env.HAPPIER_WEBAPP_URL; + + process.env.HAPPIER_SERVER_URL = 'http://example.test'; + process.env.HAPPIER_WEBAPP_URL = 'http://example.test'; + reloadConfiguration(); + + mockAxiosGet.mockImplementation(async (urlRaw: string) => { + const url = String(urlRaw); + if (url.includes('/v2/sessions/sess_integration')) { + return { status: 404, data: {}, headers: {} }; + } + if (url.includes('/v2/sessions/archived')) { + return { + status: 200, + data: { + sessions: [makeSessionFixtureRow({ id: 'sess_integration_archived_123' })], + nextCursor: null, + hasNext: false, + }, + headers: {}, + }; + } + if (url.includes('/v2/sessions')) { + return { + status: 200, + data: { + sessions: [], + nextCursor: null, + hasNext: false, + }, + headers: {}, + }; + } + throw new Error(`unexpected url: ${url}`); + }); + + try { + const { resolveSessionIdOrPrefix } = await import('./resolveSessionId'); + const res = await resolveSessionIdOrPrefix({ + credentials: { + token: 'token_test', + encryption: { type: 'legacy', secret: new Uint8Array(32).fill(1) }, + }, + idOrPrefix: 'sess_integration', + }); + + expect(res).toEqual({ ok: true, sessionId: 'sess_integration_archived_123' }); + } finally { + if (originalServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = originalServerUrl; + if (originalWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = originalWebappUrl; + reloadConfiguration(); + } + }); +}); diff --git a/apps/cli/src/sessionControl/resolveSessionId.ts b/apps/cli/src/sessionControl/resolveSessionId.ts index 04734a40e..cd7e5c109 100644 --- a/apps/cli/src/sessionControl/resolveSessionId.ts +++ b/apps/cli/src/sessionControl/resolveSessionId.ts @@ -33,30 +33,39 @@ export async function resolveSessionIdOrPrefix(params: Readonly<{ let cursor: string | undefined; const matches: string[] = []; - for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { - const page = await fetchSessionsPage({ token: params.credentials.token, cursor, limit: 200 }); - for (const row of page.sessions) { - const id = typeof (row as any)?.id === 'string' ? String((row as any).id) : ''; - if (id.startsWith(input)) { - matches.push(id); - if (matches.length > 1) { - return { ok: false, code: 'session_id_ambiguous', candidates: matches.slice(0, 10) }; + const scan = async (archivedOnly: boolean): Promise<ResolveSessionIdResult | null> => { + cursor = undefined; + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const page = await fetchSessionsPage({ token: params.credentials.token, cursor, limit: 200, archivedOnly }); + for (const row of page.sessions) { + const id = row.id; + if (id.startsWith(input)) { + matches.push(id); + if (matches.length > 1) { + return { ok: false, code: 'session_id_ambiguous', candidates: matches.slice(0, 10) }; + } } - } - // Also support resolving by exact tag match when metadata is decryptable. - const meta = tryDecryptSessionMetadata({ credentials: params.credentials, rawSession: row }); - const tag = typeof (meta as any)?.tag === 'string' ? String((meta as any).tag).trim() : ''; - if (tag && tag === input) { - matches.push(id); - if (matches.length > 1) { - return { ok: false, code: 'session_id_ambiguous', candidates: matches.slice(0, 10) }; + // Also support resolving by exact tag match when metadata is decryptable. + const meta = tryDecryptSessionMetadata({ credentials: params.credentials, rawSession: row }); + const tag = typeof (meta as any)?.tag === 'string' ? String((meta as any).tag).trim() : ''; + if (tag && tag === input) { + matches.push(id); + if (matches.length > 1) { + return { ok: false, code: 'session_id_ambiguous', candidates: matches.slice(0, 10) }; + } } } + if (!page.hasNext || !page.nextCursor) break; + cursor = page.nextCursor; } - if (!page.hasNext || !page.nextCursor) break; - cursor = page.nextCursor; - } + return null; + }; + + const activeScan = await scan(false); + if (activeScan) return activeScan; + const archivedScan = await scan(true); + if (archivedScan) return archivedScan; if (matches.length === 1) return { ok: true, sessionId: matches[0]! }; if (matches.length === 0) return { ok: false, code: 'session_not_found' }; diff --git a/apps/cli/src/sessionControl/sessionControlTimeouts.test.ts b/apps/cli/src/sessionControl/sessionControlTimeouts.test.ts new file mode 100644 index 000000000..831125bdc --- /dev/null +++ b/apps/cli/src/sessionControl/sessionControlTimeouts.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveSessionControlSocketAckTimeoutMs, resolveSessionControlSocketConnectTimeoutMs } from './sessionControlTimeouts'; + +describe('sessionControlTimeouts', () => { + const prevConnect = process.env.HAPPIER_SESSION_SOCKET_CONNECT_TIMEOUT_MS; + const prevAck = process.env.HAPPIER_SESSION_SOCKET_ACK_TIMEOUT_MS; + + afterEach(() => { + if (prevConnect === undefined) delete process.env.HAPPIER_SESSION_SOCKET_CONNECT_TIMEOUT_MS; + else process.env.HAPPIER_SESSION_SOCKET_CONNECT_TIMEOUT_MS = prevConnect; + + if (prevAck === undefined) delete process.env.HAPPIER_SESSION_SOCKET_ACK_TIMEOUT_MS; + else process.env.HAPPIER_SESSION_SOCKET_ACK_TIMEOUT_MS = prevAck; + }); + + it('defaults socket connect timeout to 10s', () => { + delete process.env.HAPPIER_SESSION_SOCKET_CONNECT_TIMEOUT_MS; + expect(resolveSessionControlSocketConnectTimeoutMs()).toBe(10_000); + }); + + it('defaults socket ack timeout to 10s', () => { + delete process.env.HAPPIER_SESSION_SOCKET_ACK_TIMEOUT_MS; + expect(resolveSessionControlSocketAckTimeoutMs()).toBe(10_000); + }); + + it('reads connect timeout from env', () => { + process.env.HAPPIER_SESSION_SOCKET_CONNECT_TIMEOUT_MS = '1234'; + expect(resolveSessionControlSocketConnectTimeoutMs()).toBe(1234); + }); + + it('reads ack timeout from env', () => { + process.env.HAPPIER_SESSION_SOCKET_ACK_TIMEOUT_MS = '2345'; + expect(resolveSessionControlSocketAckTimeoutMs()).toBe(2345); + }); + + it('rejects invalid env values and falls back', () => { + process.env.HAPPIER_SESSION_SOCKET_CONNECT_TIMEOUT_MS = '-1'; + process.env.HAPPIER_SESSION_SOCKET_ACK_TIMEOUT_MS = 'nope'; + expect(resolveSessionControlSocketConnectTimeoutMs()).toBe(10_000); + expect(resolveSessionControlSocketAckTimeoutMs()).toBe(10_000); + }); +}); + diff --git a/apps/cli/src/sessionControl/sessionControlTimeouts.ts b/apps/cli/src/sessionControl/sessionControlTimeouts.ts new file mode 100644 index 000000000..18e8b5212 --- /dev/null +++ b/apps/cli/src/sessionControl/sessionControlTimeouts.ts @@ -0,0 +1,22 @@ +function readPositiveIntEnvMs(key: string, fallback: number, opts?: Readonly<{ min?: number; max?: number }>): number { + const raw = String(process.env[key] ?? '').trim(); + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return fallback; + const min = typeof opts?.min === 'number' ? opts.min : 1; + const max = typeof opts?.max === 'number' ? opts.max : Number.MAX_SAFE_INTEGER; + if (parsed < min) return fallback; + return Math.min(max, Math.trunc(parsed)); +} + +const DEFAULT_SOCKET_CONNECT_TIMEOUT_MS = 10_000; +const DEFAULT_SOCKET_ACK_TIMEOUT_MS = 10_000; + +export function resolveSessionControlSocketConnectTimeoutMs(): number { + return readPositiveIntEnvMs('HAPPIER_SESSION_SOCKET_CONNECT_TIMEOUT_MS', DEFAULT_SOCKET_CONNECT_TIMEOUT_MS, { min: 1, max: 60_000 }); +} + +export function resolveSessionControlSocketAckTimeoutMs(): number { + return readPositiveIntEnvMs('HAPPIER_SESSION_SOCKET_ACK_TIMEOUT_MS', DEFAULT_SOCKET_ACK_TIMEOUT_MS, { min: 1, max: 60_000 }); +} + diff --git a/apps/cli/src/sessionControl/sessionEncryptionContext.plaintext.test.ts b/apps/cli/src/sessionControl/sessionEncryptionContext.plaintext.test.ts new file mode 100644 index 000000000..b39b798ac --- /dev/null +++ b/apps/cli/src/sessionControl/sessionEncryptionContext.plaintext.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { + decryptStoredSessionPayload, + encryptStoredSessionPayload, + resolveSessionStoredContentEncryptionMode, + tryDecryptSessionMetadata, +} from './sessionEncryptionContext'; + +describe('decryptStoredSessionPayload (plaintext)', () => { + const ctx = { + encryptionKey: new Uint8Array(32).fill(1), + encryptionVariant: 'legacy', + } as const; + + it('resolves stored content mode from session.encryptionMode', () => { + expect(resolveSessionStoredContentEncryptionMode(undefined)).toBe('e2ee'); + expect(resolveSessionStoredContentEncryptionMode({})).toBe('e2ee'); + expect(resolveSessionStoredContentEncryptionMode({ encryptionMode: 'e2ee' })).toBe('e2ee'); + expect(resolveSessionStoredContentEncryptionMode({ encryptionMode: 'plain' })).toBe('plain'); + }); + + it('parses JSON when mode is plain', () => { + const res = decryptStoredSessionPayload({ + mode: 'plain', + ctx, + value: '{"type":"user","text":"hi"}', + }); + expect(res).toEqual({ type: 'user', text: 'hi' }); + }); + + it('stringifies JSON when mode is plain', () => { + const wire = encryptStoredSessionPayload({ + mode: 'plain', + ctx, + payload: { type: 'user', text: 'hi' }, + }); + expect(wire).toBe('{"type":"user","text":"hi"}'); + }); + + it('returns null when plaintext JSON is malformed', () => { + const res = decryptStoredSessionPayload({ + mode: 'plain', + ctx, + value: '{', + }); + expect(res).toBeNull(); + }); + + it('decrypts plaintext session metadata without using encryption', () => { + const credentials = { + token: 't', + encryption: { type: 'legacy', secret: new Uint8Array(32).fill(9) }, + } as const; + + const res = tryDecryptSessionMetadata({ + credentials, + rawSession: { + encryptionMode: 'plain', + metadata: '{"flavor":"default","host":"example","path":"/tmp"}', + }, + }); + + expect(res).toEqual({ flavor: 'default', host: 'example', path: '/tmp' }); + }); +}); diff --git a/apps/cli/src/sessionControl/sessionEncryptionContext.ts b/apps/cli/src/sessionControl/sessionEncryptionContext.ts index b7282aa86..67477cf11 100644 --- a/apps/cli/src/sessionControl/sessionEncryptionContext.ts +++ b/apps/cli/src/sessionControl/sessionEncryptionContext.ts @@ -7,6 +7,12 @@ export type SessionEncryptionContext = Readonly<{ encryptionVariant: 'legacy' | 'dataKey'; }>; +export type SessionStoredContentEncryptionMode = 'e2ee' | 'plain'; + +export function resolveSessionStoredContentEncryptionMode(rawSession?: Readonly<{ encryptionMode?: unknown }>): SessionStoredContentEncryptionMode { + return rawSession && (rawSession as any).encryptionMode === 'plain' ? 'plain' : 'e2ee'; +} + export function resolveSessionEncryptionContextFromCredentials( credentials: Credentials, rawSession?: Readonly<{ dataEncryptionKey?: unknown }>, @@ -27,14 +33,29 @@ export function resolveSessionEncryptionContextFromCredentials( return { encryptionKey: opened ?? credentials.encryption.machineKey, encryptionVariant: 'dataKey' }; } +function tryParseJsonRecord(value: string): Record<string, unknown> | null { + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return parsed as Record<string, unknown>; + } catch { + return null; + } +} + export function tryDecryptSessionMetadata(params: Readonly<{ credentials: Credentials; - rawSession: Readonly<{ metadata?: unknown; dataEncryptionKey?: unknown }>; + rawSession: Readonly<{ metadata?: unknown; dataEncryptionKey?: unknown; encryptionMode?: unknown }>; }>): Record<string, unknown> | null { const encryptedMetadataBase64 = typeof params.rawSession.metadata === 'string' ? String(params.rawSession.metadata).trim() : ''; if (!encryptedMetadataBase64) return null; + const mode = resolveSessionStoredContentEncryptionMode(params.rawSession); + if (mode === 'plain') { + return tryParseJsonRecord(encryptedMetadataBase64); + } + const { encryptionKey, encryptionVariant } = resolveSessionEncryptionContextFromCredentials( params.credentials, params.rawSession, @@ -49,6 +70,37 @@ export function tryDecryptSessionMetadata(params: Readonly<{ } } +export function encryptStoredSessionPayload(params: Readonly<{ + mode: SessionStoredContentEncryptionMode; + ctx: SessionEncryptionContext; + payload: unknown; +}>): string { + if (params.mode === 'plain') { + return JSON.stringify(params.payload); + } + return encodeBase64(encrypt(params.ctx.encryptionKey, params.ctx.encryptionVariant, params.payload), 'base64'); +} + +export function decryptStoredSessionPayload(params: Readonly<{ + mode: SessionStoredContentEncryptionMode; + ctx: SessionEncryptionContext; + value: string; +}>): unknown { + const raw = params.value.trim(); + if (params.mode === 'plain') { + try { + return JSON.parse(raw); + } catch { + return null; + } + } + return decrypt( + params.ctx.encryptionKey, + params.ctx.encryptionVariant, + decodeBase64(raw, 'base64'), + ); +} + export function encryptSessionPayload(params: Readonly<{ ctx: SessionEncryptionContext; payload: unknown; @@ -66,4 +118,3 @@ export function decryptSessionPayload(params: Readonly<{ decodeBase64(params.ciphertextBase64, 'base64'), ); } - diff --git a/apps/cli/src/sessionControl/sessionRpc.ts b/apps/cli/src/sessionControl/sessionRpc.ts index abab99b53..d8b7a0940 100644 --- a/apps/cli/src/sessionControl/sessionRpc.ts +++ b/apps/cli/src/sessionControl/sessionRpc.ts @@ -2,6 +2,8 @@ import { createSessionScopedSocket } from '@/api/session/sockets'; import { SOCKET_RPC_EVENTS } from '@happier-dev/protocol/socketRpc'; import { decodeBase64, decrypt, encodeBase64, encrypt } from '@/api/encryption'; import type { SessionEncryptionContext } from './sessionEncryptionContext'; +import { resolveSessionControlSocketConnectTimeoutMs } from './sessionControlTimeouts'; +import { waitForSocketConnect } from './waitForSocketConnect'; export async function callSessionRpc(params: Readonly<{ token: string; @@ -13,21 +15,11 @@ export async function callSessionRpc(params: Readonly<{ }>): Promise<unknown> { const socket = createSessionScopedSocket({ token: params.token, sessionId: params.sessionId }); const timeoutMs = typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? params.timeoutMs : 20_000; + const connectTimeoutMs = typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? timeoutMs : resolveSessionControlSocketConnectTimeoutMs(); - const waitForConnect = new Promise<void>((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Socket connect timeout')), timeoutMs); - socket.on('connect', () => { - clearTimeout(timer); - resolve(); - }); - socket.on('connect_error', (err) => { - clearTimeout(timer); - reject(err instanceof Error ? err : new Error(String(err))); - }); - }); - + const connectPromise = waitForSocketConnect(socket as unknown as import('socket.io-client').Socket, connectTimeoutMs); socket.connect(); - await waitForConnect; + await connectPromise; const encryptedParams = encodeBase64(encrypt(params.ctx.encryptionKey, params.ctx.encryptionVariant, params.request), 'base64'); const response = await new Promise<{ ok: boolean; result?: string; error?: string }>((resolve, reject) => { @@ -57,4 +49,3 @@ export async function callSessionRpc(params: Readonly<{ if (!encryptedResult) return null; return decrypt(params.ctx.encryptionKey, params.ctx.encryptionVariant, decodeBase64(encryptedResult, 'base64')); } - diff --git a/apps/cli/src/sessionControl/sessionSocketAgentState.ts b/apps/cli/src/sessionControl/sessionSocketAgentState.ts index 909e24179..58d6ff9bf 100644 --- a/apps/cli/src/sessionControl/sessionSocketAgentState.ts +++ b/apps/cli/src/sessionControl/sessionSocketAgentState.ts @@ -3,7 +3,7 @@ import type { Socket } from 'socket.io-client'; import { createSessionScopedSocket } from '@/api/session/sockets'; import { UpdateContainerSchema, type UpdateContainer } from '@happier-dev/protocol/updates'; import { decodeBase64, decrypt } from '@/api/encryption'; -import type { SessionEncryptionContext } from './sessionEncryptionContext'; +import type { SessionEncryptionContext, SessionStoredContentEncryptionMode } from './sessionEncryptionContext'; export type AgentStateSummary = Readonly<{ controlledByUser?: boolean; @@ -29,6 +29,7 @@ export async function waitForIdleViaSocket(params: Readonly<{ token: string; sessionId: string; ctx: SessionEncryptionContext; + sessionEncryptionMode: SessionStoredContentEncryptionMode; timeoutMs: number; // Seed with the latest agentState ciphertext from snapshot, if available. initialAgentStateCiphertextBase64: string | null; @@ -36,11 +37,14 @@ export async function waitForIdleViaSocket(params: Readonly<{ const initial = (() => { if (!params.initialAgentStateCiphertextBase64) return null; try { - const decrypted = decrypt( - params.ctx.encryptionKey, - params.ctx.encryptionVariant, - decodeBase64(params.initialAgentStateCiphertextBase64, 'base64'), - ); + const decrypted = + params.sessionEncryptionMode === 'plain' + ? JSON.parse(params.initialAgentStateCiphertextBase64) + : decrypt( + params.ctx.encryptionKey, + params.ctx.encryptionVariant, + decodeBase64(params.initialAgentStateCiphertextBase64, 'base64'), + ); return summarizeAgentState(decrypted); } catch { return null; @@ -99,11 +103,14 @@ export async function waitForIdleViaSocket(params: Readonly<{ if (typeof agentStateCiphertext !== 'string' || agentStateCiphertext.trim().length === 0) return; try { - const decrypted = decrypt( - params.ctx.encryptionKey, - params.ctx.encryptionVariant, - decodeBase64(agentStateCiphertext, 'base64'), - ); + const decrypted = + params.sessionEncryptionMode === 'plain' + ? JSON.parse(agentStateCiphertext) + : decrypt( + params.ctx.encryptionKey, + params.ctx.encryptionVariant, + decodeBase64(agentStateCiphertext, 'base64'), + ); const summary = summarizeAgentState(decrypted); if (!isIdle(summary)) return; } catch { @@ -127,6 +134,7 @@ export async function readLatestAgentStateSummaryViaSocket(params: Readonly<{ token: string; sessionId: string; ctx: SessionEncryptionContext; + sessionEncryptionMode: SessionStoredContentEncryptionMode; timeoutMs: number; }>): Promise<AgentStateSummary | null> { const socket = createSessionScopedSocket({ token: params.token, sessionId: params.sessionId }) as unknown as Socket; @@ -176,11 +184,14 @@ export async function readLatestAgentStateSummaryViaSocket(params: Readonly<{ if (typeof agentStateCiphertext !== 'string' || agentStateCiphertext.trim().length === 0) return; try { - const decrypted = decrypt( - params.ctx.encryptionKey, - params.ctx.encryptionVariant, - decodeBase64(agentStateCiphertext, 'base64'), - ); + const decrypted = + params.sessionEncryptionMode === 'plain' + ? JSON.parse(agentStateCiphertext) + : decrypt( + params.ctx.encryptionKey, + params.ctx.encryptionVariant, + decodeBase64(agentStateCiphertext, 'base64'), + ); const summary = summarizeAgentState(decrypted); clearTimeout(timer); cleanup(); diff --git a/apps/cli/src/sessionControl/sessionSocketSendMessage.ts b/apps/cli/src/sessionControl/sessionSocketSendMessage.ts new file mode 100644 index 000000000..1b0604306 --- /dev/null +++ b/apps/cli/src/sessionControl/sessionSocketSendMessage.ts @@ -0,0 +1,72 @@ +import type { Socket } from 'socket.io-client'; + +import { MessageAckResponseSchema } from '@happier-dev/protocol/updates'; + +import { createSessionScopedSocket } from '@/api/session/sockets'; +import type { SessionStoredMessageContent } from '@happier-dev/protocol'; +import { resolveSessionControlSocketAckTimeoutMs, resolveSessionControlSocketConnectTimeoutMs } from './sessionControlTimeouts'; +import { waitForSocketConnect } from './waitForSocketConnect'; + +export async function sendSessionMessageViaSocketCommitted(params: Readonly<{ + token: string; + sessionId: string; + content: SessionStoredMessageContent; + localId: string; + connectTimeoutMs?: number; + ackTimeoutMs?: number; + sentFrom?: string; + permissionMode?: string; +}>): Promise<void> { + const connectTimeoutMs = + typeof params.connectTimeoutMs === 'number' && Number.isFinite(params.connectTimeoutMs) && params.connectTimeoutMs > 0 + ? Math.min(60_000, Math.trunc(params.connectTimeoutMs)) + : resolveSessionControlSocketConnectTimeoutMs(); + const ackTimeoutMs = + typeof params.ackTimeoutMs === 'number' && Number.isFinite(params.ackTimeoutMs) && params.ackTimeoutMs > 0 + ? Math.min(60_000, Math.trunc(params.ackTimeoutMs)) + : resolveSessionControlSocketAckTimeoutMs(); + + const socket = createSessionScopedSocket({ token: params.token, sessionId: params.sessionId }) as unknown as Socket; + const connectPromise = waitForSocketConnect(socket, connectTimeoutMs); + socket.connect(); + await connectPromise; + + try { + const rawAck = await new Promise<unknown>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Socket message ACK timeout')), ackTimeoutMs); + (socket as any).emit( + 'message', + { + sid: params.sessionId, + message: params.content, + localId: params.localId, + ...(params.sentFrom ? { sentFrom: params.sentFrom } : {}), + ...(params.permissionMode ? { permissionMode: params.permissionMode } : {}), + }, + (answer: unknown) => { + clearTimeout(timer); + resolve(answer); + }, + ); + }); + + const parsed = MessageAckResponseSchema.safeParse(rawAck); + if (!parsed.success) { + const err = new Error('Invalid message ACK payload'); + (err as any).code = 'unknown_error'; + throw err; + } + if (parsed.data.ok !== true) { + const err = new Error(parsed.data.error ?? 'Send failed'); + (err as any).code = 'unknown_error'; + throw err; + } + } finally { + try { + socket.disconnect(); + socket.close(); + } catch { + // ignore + } + } +} diff --git a/apps/cli/src/sessionControl/sessionSummary.test.ts b/apps/cli/src/sessionControl/sessionSummary.test.ts new file mode 100644 index 000000000..ba7f41c13 --- /dev/null +++ b/apps/cli/src/sessionControl/sessionSummary.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { encodeBase64, encryptLegacy } from '@/api/encryption'; +import { makeSessionFixtureRow } from '@/sessionControl/testFixtures'; +import { summarizeSessionRow } from '@/sessionControl/sessionSummary'; + +describe('summarizeSessionRow', () => { + const credentials = { + token: 'token', + encryption: { + type: 'legacy', + secret: new Uint8Array(32).fill(5), + }, + } satisfies { + token: string; + encryption: { + type: 'legacy'; + secret: Uint8Array; + }; + }; + + it('adds system session fields when metadata includes systemSessionV1', () => { + const metadata = encodeBase64(encryptLegacy({ + tag: 'MySession', + systemSessionV1: { + v: 1, + key: 'voice_carrier', + hidden: true, + }, + }, credentials.encryption.secret)); + + const session = summarizeSessionRow({ + credentials, + row: makeSessionFixtureRow({ + id: 'session-system', + metadata, + metadataVersion: 1, + }), + }); + + expect(session.isSystem).toBe(true); + expect(session.systemPurpose).toBe('voice_carrier'); + }); + + it('omits system session fields when metadata is missing systemSessionV1', () => { + const metadata = encodeBase64(encryptLegacy({ tag: 'MySession' }, credentials.encryption.secret)); + const session = summarizeSessionRow({ + credentials, + row: makeSessionFixtureRow({ + id: 'session-user', + metadata, + metadataVersion: 1, + }), + }); + + expect(session.isSystem).toBeUndefined(); + expect(session.systemPurpose).toBeUndefined(); + }); + + it('is tolerant of malformed metadata', () => { + const session = summarizeSessionRow({ + credentials, + row: makeSessionFixtureRow({ + id: 'session-malformed', + metadata: 'not-base64', + }), + }); + + expect(session.isSystem).toBeUndefined(); + expect(session.systemPurpose).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/sessionControl/sessionSummary.ts b/apps/cli/src/sessionControl/sessionSummary.ts index 27f8904e1..4baf38da9 100644 --- a/apps/cli/src/sessionControl/sessionSummary.ts +++ b/apps/cli/src/sessionControl/sessionSummary.ts @@ -1,30 +1,12 @@ import type { Credentials } from '@/persistence'; import { tryDecryptSessionMetadata } from './sessionEncryptionContext'; import type { RawSessionListRow, RawSessionRecord } from './sessionsHttp'; +import { + readSystemSessionMetadataFromMetadata, + type SessionSummary as ProtocolSessionSummary, +} from '@happier-dev/protocol'; -export type SessionSummary = Readonly<{ - id: string; - createdAt: number; - updatedAt: number; - active: boolean; - activeAt: number; - pendingCount?: number; - tag?: string; - path?: string; - host?: string; - share?: { accessLevel: string; canApprovePermissions: boolean } | null; - isSystem?: boolean; - systemPurpose?: string | null; - encryption: { type: 'legacy' | 'dataKey' }; -}>; - -function readNumber(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : 0; -} - -function readString(value: unknown): string { - return typeof value === 'string' ? value : ''; -} +export type SessionSummary = Readonly<ProtocolSessionSummary>; function readShare(value: unknown): { accessLevel: string; canApprovePermissions: boolean } | null | undefined { if (value === null) return null; @@ -40,23 +22,32 @@ export function summarizeSessionRow(params: Readonly<{ credentials: Credentials; row: RawSessionListRow; }>): SessionSummary { - const id = readString(params.row.id).trim(); + const id = params.row.id.trim(); const metadata = tryDecryptSessionMetadata({ credentials: params.credentials, rawSession: params.row }); const tag = typeof (metadata as any)?.tag === 'string' ? String((metadata as any).tag) : undefined; + const title = typeof (metadata as any)?.summary?.text === 'string' ? String((metadata as any).summary.text).trim() : undefined; const path = typeof (metadata as any)?.path === 'string' ? String((metadata as any).path) : undefined; const host = typeof (metadata as any)?.host === 'string' ? String((metadata as any).host) : undefined; + const systemMetadata = metadata === null ? null : readSystemSessionMetadataFromMetadata({ metadata }); + const isSystem = systemMetadata !== null; + const archivedAt = (params.row as any)?.archivedAt; + const archivedAtValue = typeof archivedAt === 'number' && Number.isFinite(archivedAt) && archivedAt >= 0 ? archivedAt : archivedAt === null ? null : undefined; return { id, - createdAt: readNumber(params.row.createdAt), - updatedAt: readNumber(params.row.updatedAt), - active: Boolean((params.row as any).active), - activeAt: readNumber((params.row as any).activeAt), - ...(typeof (params.row as any).pendingCount === 'number' ? { pendingCount: (params.row as any).pendingCount } : {}), + createdAt: params.row.createdAt, + updatedAt: params.row.updatedAt, + active: params.row.active, + activeAt: params.row.activeAt, + ...(archivedAtValue !== undefined ? { archivedAt: archivedAtValue } : {}), + ...(typeof params.row.pendingCount === 'number' ? { pendingCount: params.row.pendingCount } : {}), ...(tag ? { tag } : {}), + ...(title ? { title } : {}), ...(path ? { path } : {}), ...(host ? { host } : {}), - ...(readShare((params.row as any).share) !== undefined ? { share: readShare((params.row as any).share) } : {}), + ...(isSystem ? { isSystem, systemPurpose: systemMetadata?.key ?? null } : {}), + ...(readShare(params.row.share) !== undefined ? { share: readShare(params.row.share) } : {}), + ...((params.row as any)?.encryptionMode ? { encryptionMode: (params.row as any).encryptionMode } : {}), encryption: { type: params.credentials.encryption.type }, }; } @@ -66,6 +57,5 @@ export function summarizeSessionRecord(params: Readonly<{ session: RawSessionRecord; }>): SessionSummary { // The /v2/sessions/:id response includes similar shape, so reuse the same summarization logic. - return summarizeSessionRow({ credentials: params.credentials, row: params.session as any }); + return summarizeSessionRow({ credentials: params.credentials, row: params.session }); } - diff --git a/apps/cli/src/sessionControl/sessionsHttp.compat.test.ts b/apps/cli/src/sessionControl/sessionsHttp.compat.test.ts index 3531fedf8..2f0545c3e 100644 --- a/apps/cli/src/sessionControl/sessionsHttp.compat.test.ts +++ b/apps/cli/src/sessionControl/sessionsHttp.compat.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import axios from 'axios'; +import { makeSessionFixtureListResponse, makeSessionFixtureRow } from './testFixtures'; import { fetchSessionByIdCompat } from './sessionsHttp'; @@ -14,11 +15,9 @@ describe('sessionControl.sessionsHttp.fetchSessionByIdCompat', () => { } as any) .mockResolvedValueOnce({ status: 200, - data: { - sessions: [{ id: 's1', metadataVersion: 0, agentStateVersion: 0, dataEncryptionKey: 'dek' }], - hasNext: false, - nextCursor: null, - }, + data: makeSessionFixtureListResponse([ + makeSessionFixtureRow({ id: 's1', metadataVersion: 0, agentStateVersion: 0, dataEncryptionKey: 'dek' }), + ]), } as any); const res = await fetchSessionByIdCompat({ token: 't', sessionId: 's1' }); @@ -41,5 +40,17 @@ describe('sessionControl.sessionsHttp.fetchSessionByIdCompat', () => { expect(getSpy).toHaveBeenCalledTimes(1); expect(String(getSpy.mock.calls[0]?.[0])).toContain('/v2/sessions/s1'); }); -}); + it('throws on malformed /v2/sessions payload when scanning fallback route', async () => { + const getSpy = vi.spyOn(axios, 'get'); + getSpy + .mockResolvedValueOnce({ status: 404, data: { error: 'Not found', path: '/v2/sessions/s1', method: 'GET' } } as any) + .mockResolvedValueOnce({ + status: 200, + data: { sessions: [{ id: 's1' }], nextCursor: null, hasNext: false }, + } as any); + + await expect(fetchSessionByIdCompat({ token: 't', sessionId: 's1' })).rejects.toThrow('Unexpected /v2/sessions response shape'); + expect(getSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/cli/src/sessionControl/sessionsHttp.ts b/apps/cli/src/sessionControl/sessionsHttp.ts index f95535c85..4a08689c5 100644 --- a/apps/cli/src/sessionControl/sessionsHttp.ts +++ b/apps/cli/src/sessionControl/sessionsHttp.ts @@ -1,37 +1,28 @@ import axios from 'axios'; +import { + type V2SessionByIdResponse, + type V2SessionListResponse, + V2SessionByIdResponseSchema, + V2SessionListResponseSchema, + V2SessionMessageResponseSchema, +} from '@happier-dev/protocol'; import type { Credentials } from '@/persistence'; +import { resolveSessionEncryptionContext } from '@/api/client/encryptionKey'; +import { encodeBase64, encrypt } from '@/api/encryption'; +import { resolveSessionCreateEncryptionMode } from '@/api/session/resolveSessionCreateEncryptionMode'; import { resolveServerHttpBaseUrl } from './serverHttpBaseUrl'; -export type RawSessionRecord = Readonly<{ - id?: unknown; - metadata?: unknown; - metadataVersion?: unknown; - agentState?: unknown; - agentStateVersion?: unknown; - dataEncryptionKey?: unknown; - createdAt?: unknown; - updatedAt?: unknown; - active?: unknown; - activeAt?: unknown; - pendingCount?: unknown; - share?: unknown; -}>; - -export type RawSessionListRow = Readonly<{ - id?: unknown; - createdAt?: unknown; - updatedAt?: unknown; - active?: unknown; - activeAt?: unknown; - pendingCount?: unknown; - metadata?: unknown; - metadataVersion?: unknown; - agentState?: unknown; - agentStateVersion?: unknown; - dataEncryptionKey?: unknown; - share?: unknown; -}>; +export type RawSessionRecord = V2SessionByIdResponse['session']; +export type RawSessionListRow = V2SessionListResponse['sessions'][number]; + +function parseOrThrow<T>(schema: { safeParse: (value: unknown) => { success: boolean; data?: T } }, payload: unknown, message: string): T { + const parsed = schema.safeParse(payload); + if (!parsed.success || !parsed.data) { + throw new Error(message); + } + return parsed.data; +} export async function fetchSessionById(params: Readonly<{ token: string; sessionId: string }>): Promise<RawSessionRecord | null> { const serverUrl = resolveServerHttpBaseUrl(); @@ -52,11 +43,7 @@ export async function fetchSessionById(params: Readonly<{ token: string; session throw new Error(`Unexpected status from /v2/sessions/${params.sessionId}: ${response.status}`); } - const session = (response.data as any)?.session; - if (!session || typeof session !== 'object') { - throw new Error('Unexpected /v2/sessions response shape'); - } - return session as RawSessionRecord; + return parseOrThrow<V2SessionByIdResponse>(V2SessionByIdResponseSchema, response.data, 'Unexpected /v2/sessions response shape').session; } function looksLikeMissingV2SessionRoute404(data: unknown, sessionId: string): boolean { @@ -101,11 +88,7 @@ export async function fetchSessionByIdCompat(params: Readonly<{ token: string; s throw new Error(`Unexpected status from /v2/sessions/${params.sessionId}: ${response.status}`); } - const session = (response.data as any)?.session; - if (!session || typeof session !== 'object') { - throw new Error('Unexpected /v2/sessions response shape'); - } - return session as RawSessionRecord; + return parseOrThrow<V2SessionByIdResponse>(V2SessionByIdResponseSchema, response.data, 'Unexpected /v2/sessions response shape').session; } export async function fetchSessionsPage(params: Readonly<{ @@ -113,6 +96,7 @@ export async function fetchSessionsPage(params: Readonly<{ cursor?: string; limit?: number; activeOnly?: boolean; + archivedOnly?: boolean; }>): Promise<{ sessions: RawSessionListRow[]; nextCursor: string | null; @@ -121,7 +105,11 @@ export async function fetchSessionsPage(params: Readonly<{ const serverUrl = resolveServerHttpBaseUrl(); const limit = typeof params.limit === 'number' && Number.isFinite(params.limit) ? params.limit : undefined; - const path = params.activeOnly ? '/v2/sessions/active' : '/v2/sessions'; + if (params.activeOnly && params.archivedOnly) { + throw new Error('Cannot combine activeOnly and archivedOnly'); + } + + const path = params.activeOnly ? '/v2/sessions/active' : params.archivedOnly ? '/v2/sessions/archived' : '/v2/sessions'; const response = await axios.get(`${serverUrl}${path}`, { headers: { Authorization: `Bearer ${params.token}`, @@ -141,15 +129,20 @@ export async function fetchSessionsPage(params: Readonly<{ throw new Error(`Unexpected status from ${path}: ${response.status}`); } - const rawSessions = (response.data as any)?.sessions; - if (!Array.isArray(rawSessions)) { + const parsed = parseOrThrow<V2SessionListResponse>( + V2SessionListResponseSchema, + response.data, + `Unexpected ${path} response shape`, + ); + + if (!Array.isArray(parsed.sessions)) { throw new Error(`Unexpected ${path} response shape`); } return { - sessions: rawSessions as RawSessionListRow[], - nextCursor: typeof (response.data as any)?.nextCursor === 'string' ? String((response.data as any).nextCursor) : null, - hasNext: Boolean((response.data as any)?.hasNext), + sessions: parsed.sessions, + nextCursor: typeof parsed.nextCursor === 'string' ? parsed.nextCursor : null, + hasNext: Boolean(parsed.hasNext), }; } @@ -185,27 +178,57 @@ export async function commitSessionEncryptedMessage(params: Readonly<{ throw new Error(`Unexpected status from /v2/sessions/${params.sessionId}/messages: ${response.status}`); } + const parsed = parseOrThrow( + V2SessionMessageResponseSchema, + response.data, + `Unexpected /v2/sessions/${params.sessionId}/messages response shape`, + ); + return { - didWrite: Boolean((response.data as any)?.didWrite), - messageId: String((response.data as any)?.message?.id ?? ''), - seq: Number((response.data as any)?.message?.seq ?? 0), - createdAt: Number((response.data as any)?.message?.createdAt ?? 0), + didWrite: parsed.didWrite, + messageId: String(parsed.message?.id ?? ''), + seq: Number(parsed.message?.seq ?? 0), + createdAt: Number(parsed.message?.createdAt ?? 0), }; } export async function getOrCreateSessionByTag(params: Readonly<{ credentials: Credentials; tag: string; - metadataCiphertext: string; - agentStateCiphertext: string | null; - dataEncryptionKey: string | null; + metadata: Record<string, unknown>; + agentState: Record<string, unknown> | null; }>): Promise<{ session: RawSessionRecord }> { const serverUrl = resolveServerHttpBaseUrl(); + + const { desiredSessionEncryptionMode, serverSupportsFeatureSnapshot } = await resolveSessionCreateEncryptionMode({ + token: params.credentials.token, + serverBaseUrl: serverUrl, + }); + + const { encryptionKey, encryptionVariant, dataEncryptionKey } = resolveSessionEncryptionContext(params.credentials); + + const metadataPayload = + desiredSessionEncryptionMode === 'plain' + ? JSON.stringify(params.metadata) + : encodeBase64(encrypt(encryptionKey, encryptionVariant, params.metadata)); + const agentStatePayload = + desiredSessionEncryptionMode === 'plain' + ? (params.agentState ? JSON.stringify(params.agentState) : null) + : (params.agentState ? encodeBase64(encrypt(encryptionKey, encryptionVariant, params.agentState)) : null); + + const dataEncryptionKeyPayload = + desiredSessionEncryptionMode === 'plain' + ? null + : dataEncryptionKey + ? encodeBase64(dataEncryptionKey) + : null; + const response = await axios.post(`${serverUrl}/v1/sessions`, { tag: params.tag, - metadata: params.metadataCiphertext, - agentState: params.agentStateCiphertext, - dataEncryptionKey: params.dataEncryptionKey, + metadata: metadataPayload, + agentState: agentStatePayload, + dataEncryptionKey: dataEncryptionKeyPayload, + ...(serverSupportsFeatureSnapshot ? { encryptionMode: desiredSessionEncryptionMode } : {}), }, { headers: { Authorization: `Bearer ${params.credentials.token}`, @@ -222,9 +245,78 @@ export async function getOrCreateSessionByTag(params: Readonly<{ throw new Error(`Unexpected status from /v1/sessions: ${response.status}`); } - const session = (response.data as any)?.session; - if (!session || typeof session !== 'object') { + const parsed = parseOrThrow<V2SessionByIdResponse>( + V2SessionByIdResponseSchema, + response.data, + 'Unexpected /v1/sessions response shape', + ); + if (!parsed || !parsed.session || typeof parsed.session !== 'object') { throw new Error('Unexpected /v1/sessions response shape'); } - return { session: session as RawSessionRecord }; + return { session: parsed.session }; +} + +async function postArchiveMutation(params: Readonly<{ + token: string; + sessionId: string; + op: 'archive' | 'unarchive'; +}>): Promise<{ archivedAt: number | null }> { + const serverUrl = resolveServerHttpBaseUrl(); + const response = await axios.post( + `${serverUrl}/v2/sessions/${params.sessionId}/${params.op}`, + {}, + { + headers: { + Authorization: `Bearer ${params.token}`, + 'Content-Type': 'application/json', + }, + timeout: 10_000, + validateStatus: () => true, + }, + ); + + if (response.status === 401 || response.status === 403) { + const err = new Error(`Unauthorized (${response.status})`); + (err as any).code = 'not_authenticated'; + throw err; + } + if (response.status === 404) { + const err = new Error('Session not found'); + (err as any).code = 'session_not_found'; + throw err; + } + if (response.status === 409 && params.op === 'archive') { + const err = new Error('Cannot archive an active session'); + (err as any).code = 'session_active'; + throw err; + } + if (response.status !== 200) { + throw new Error(`Unexpected status from /v2/sessions/${params.sessionId}/${params.op}: ${response.status}`); + } + + const ok = response.data && typeof response.data === 'object' && (response.data as any).success === true; + if (!ok) { + throw new Error(`Unexpected /v2/sessions/${params.sessionId}/${params.op} response shape`); + } + + const archivedAt = (response.data as any).archivedAt; + if (archivedAt === null) return { archivedAt: null }; + if (typeof archivedAt === 'number' && Number.isFinite(archivedAt) && archivedAt >= 0) return { archivedAt }; + throw new Error(`Unexpected /v2/sessions/${params.sessionId}/${params.op} response shape`); +} + +export async function archiveSession(params: Readonly<{ token: string; sessionId: string }>): Promise<{ archivedAt: number }> { + const res = await postArchiveMutation({ token: params.token, sessionId: params.sessionId, op: 'archive' }); + if (typeof res.archivedAt !== 'number') { + throw new Error('Unexpected archive response (archivedAt is null)'); + } + return { archivedAt: res.archivedAt }; +} + +export async function unarchiveSession(params: Readonly<{ token: string; sessionId: string }>): Promise<{ archivedAt: null }> { + const res = await postArchiveMutation({ token: params.token, sessionId: params.sessionId, op: 'unarchive' }); + if (res.archivedAt !== null) { + throw new Error('Unexpected unarchive response (archivedAt is not null)'); + } + return { archivedAt: null }; } diff --git a/apps/cli/src/sessionControl/testFixtures.ts b/apps/cli/src/sessionControl/testFixtures.ts new file mode 100644 index 000000000..154ac2e8c --- /dev/null +++ b/apps/cli/src/sessionControl/testFixtures.ts @@ -0,0 +1,41 @@ +import type { V2SessionListResponse, V2SessionRecord } from '@happier-dev/protocol'; + +export type SessionFixtureRow = V2SessionRecord; +export type SessionFixtureListResponse = V2SessionListResponse; + +export function makeSessionFixtureRow( + overrides: Partial<SessionFixtureRow> & Pick<SessionFixtureRow, 'id'>, +): SessionFixtureRow { + const { id, ...rest } = overrides; + return { + id, + seq: 0, + createdAt: 0, + updatedAt: 0, + active: false, + activeAt: 0, + archivedAt: null, + metadata: 'metadata', + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: null, + ...rest, + }; +} + +export function makeSessionFixtureListResponse( + rows: Array<SessionFixtureRow>, + options: { + nextCursor?: string | null; + hasNext?: boolean; + } = {}, +): SessionFixtureListResponse { + return { + sessions: rows, + nextCursor: options.nextCursor ?? null, + hasNext: options.hasNext ?? false, + }; +} diff --git a/apps/cli/src/sessionControl/updateSessionMetadataWithRetry.ts b/apps/cli/src/sessionControl/updateSessionMetadataWithRetry.ts new file mode 100644 index 000000000..ae9f08c4c --- /dev/null +++ b/apps/cli/src/sessionControl/updateSessionMetadataWithRetry.ts @@ -0,0 +1,111 @@ +import type { Socket } from 'socket.io-client'; + +import { createSessionScopedSocket } from '@/api/session/sockets'; +import type { Credentials } from '@/persistence'; +import { + decryptStoredSessionPayload, + encryptStoredSessionPayload, + resolveSessionEncryptionContextFromCredentials, + resolveSessionStoredContentEncryptionMode, +} from './sessionEncryptionContext'; +import { resolveSessionControlSocketConnectTimeoutMs } from './sessionControlTimeouts'; +import { waitForSocketConnect } from './waitForSocketConnect'; + +type UpdateMetadataAck = + | { result: 'success'; version: number; metadata: string } + | { result: 'version-mismatch'; version: number; metadata: string } + | { result: 'forbidden' } + | { result: 'error' }; + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +async function emitUpdateMetadataWithAck(socket: Socket, payload: { sid: string; expectedVersion: number; metadata: string }): Promise<UpdateMetadataAck> { + const res = await new Promise<UpdateMetadataAck>((resolve) => { + (socket as any).emit('update-metadata', payload, (answer: any) => resolve(answer as UpdateMetadataAck)); + }); + return res; +} + +export async function updateSessionMetadataWithRetry(params: Readonly<{ + token: string; + credentials: Credentials; + sessionId: string; + rawSession: Readonly<{ metadata: string; metadataVersion: number; encryptionMode?: unknown; dataEncryptionKey?: unknown }>; + updater: (metadata: Record<string, unknown>) => Record<string, unknown>; + maxAttempts?: number; +}>): Promise<{ version: number; metadata: Record<string, unknown> }> { + const mode = resolveSessionStoredContentEncryptionMode(params.rawSession); + const ctx = resolveSessionEncryptionContextFromCredentials(params.credentials, params.rawSession); + + let expectedVersion = params.rawSession.metadataVersion; + let currentWireValue = String((params.rawSession as any).metadata ?? '').trim(); + + const initialDecrypted = asRecord(decryptStoredSessionPayload({ mode, ctx, value: currentWireValue })); + if (!initialDecrypted) { + const err = new Error('Unsupported session metadata payload'); + (err as any).code = 'unsupported'; + throw err; + } + let currentDecrypted: Record<string, unknown> = initialDecrypted; + + const socket = createSessionScopedSocket({ token: params.token, sessionId: params.sessionId }) as unknown as Socket; + const connectPromise = waitForSocketConnect(socket, resolveSessionControlSocketConnectTimeoutMs()); + socket.connect(); + await connectPromise; + + const maxAttempts = typeof params.maxAttempts === 'number' && Number.isFinite(params.maxAttempts) && params.maxAttempts > 0 ? Math.min(10, params.maxAttempts) : 6; + + try { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const updated = params.updater(currentDecrypted); + const updatedWireValue = encryptStoredSessionPayload({ mode, ctx, payload: updated }); + + const ack = await emitUpdateMetadataWithAck(socket, { + sid: params.sessionId, + expectedVersion, + metadata: updatedWireValue, + }); + + if (ack && ack.result === 'success') { + const next = asRecord(decryptStoredSessionPayload({ mode, ctx, value: String(ack.metadata ?? '') })); + return { version: ack.version, metadata: next ?? updated }; + } + + if (ack && ack.result === 'version-mismatch') { + expectedVersion = ack.version; + currentWireValue = String(ack.metadata ?? '').trim(); + const next = asRecord(decryptStoredSessionPayload({ mode, ctx, value: currentWireValue })); + if (next) currentDecrypted = next; + // small backoff to reduce tight contention + if (attempt < maxAttempts - 1) { + await new Promise((r) => setTimeout(r, Math.min(50 * (attempt + 1), 250))); + } + continue; + } + + if (ack && ack.result === 'forbidden') { + const err = new Error('Forbidden'); + (err as any).code = 'not_authenticated'; + throw err; + } + + const err = new Error('Metadata update failed'); + (err as any).code = 'unknown_error'; + throw err; + } + + const err = new Error('Metadata update conflict'); + (err as any).code = 'conflict'; + throw err; + } finally { + try { + socket.disconnect(); + socket.close(); + } catch { + // ignore + } + } +} diff --git a/apps/cli/src/sessionControl/waitForSocketConnect.ts b/apps/cli/src/sessionControl/waitForSocketConnect.ts new file mode 100644 index 000000000..f6bceec35 --- /dev/null +++ b/apps/cli/src/sessionControl/waitForSocketConnect.ts @@ -0,0 +1,16 @@ +import type { Socket } from 'socket.io-client'; + +export async function waitForSocketConnect(socket: Socket, timeoutMs: number): Promise<void> { + await new Promise<void>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Socket connect timeout')), timeoutMs); + socket.on('connect', () => { + clearTimeout(timer); + resolve(); + }); + socket.on('connect_error', (err) => { + clearTimeout(timer); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); +} + diff --git a/apps/cli/src/settings/notifications/permissionRequestPush.ts b/apps/cli/src/settings/notifications/permissionRequestPush.ts index 1e965dca4..a1ca77919 100644 --- a/apps/cli/src/settings/notifications/permissionRequestPush.ts +++ b/apps/cli/src/settings/notifications/permissionRequestPush.ts @@ -1,11 +1,13 @@ import axios from 'axios'; import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; +import { isDefaultWriteLikeToolName } from '@/agent/permissions/CodexLikePermissionHandler'; import { logger } from '@/ui/logger'; import { getActiveAccountSettingsSnapshot } from '@/settings/accountSettings/activeAccountSettingsSnapshot'; import { shouldSendPermissionRequestPushNotification } from './notificationsPolicy'; import type { AccountSettings } from '@happier-dev/protocol'; +import type { PermissionMode } from '@/api/types'; type PushSender = Readonly<{ sendToAllDevices: (title: string, body: string, data: Record<string, unknown>) => void; @@ -38,12 +40,25 @@ export function sendPermissionRequestPushNotification(params: Readonly<{ } } +/** + * Returns true when the given permission mode would auto-approve the tool, + * meaning a push notification would just be noise. + */ +function isAutoApprovedByMode(permissionMode: PermissionMode | null | undefined, toolName: string): boolean { + if (!permissionMode) return false; + if (permissionMode === 'yolo' || permissionMode === 'bypassPermissions') return true; + if (permissionMode === 'safe-yolo' && !isDefaultWriteLikeToolName(toolName)) return true; + return false; +} + export function sendPermissionRequestPushNotificationForActiveAccount(params: Readonly<{ pushSender: PushSender; sessionId: string; permissionId: string; toolName: string; + permissionMode?: PermissionMode | null; }>): void { + if (isAutoApprovedByMode(params.permissionMode, params.toolName)) return; const settings = getActiveAccountSettingsSnapshot()?.settings ?? null; sendPermissionRequestPushNotification({ ...params, settings }); } diff --git a/apps/cli/src/terminal/tmux/startHappyHeadlessInTmux.test.ts b/apps/cli/src/terminal/tmux/startHappyHeadlessInTmux.test.ts index 0b48457ca..15ee09197 100644 --- a/apps/cli/src/terminal/tmux/startHappyHeadlessInTmux.test.ts +++ b/apps/cli/src/terminal/tmux/startHappyHeadlessInTmux.test.ts @@ -31,7 +31,7 @@ vi.mock('@/integrations/tmux', () => { }); vi.mock('@/utils/spawnHappyCLI', () => ({ - buildHappyCliSubprocessInvocation: () => ({ runtime: 'node', argv: ['happy'] }), + buildHappyCliSubprocessLaunchSpec: () => ({ runtime: 'node', filePath: 'node', args: ['happy'] }), })); describe.sequential('startHappyHeadlessInTmux', () => { diff --git a/apps/cli/src/terminal/tmux/startHappyHeadlessInTmux.ts b/apps/cli/src/terminal/tmux/startHappyHeadlessInTmux.ts index f15482381..968d1026d 100644 --- a/apps/cli/src/terminal/tmux/startHappyHeadlessInTmux.ts +++ b/apps/cli/src/terminal/tmux/startHappyHeadlessInTmux.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; -import { buildHappyCliSubprocessInvocation } from '@/utils/spawnHappyCLI'; +import { buildHappyCliSubprocessLaunchSpec } from '@/utils/spawnHappyCLI'; import { isTmuxAvailable, selectPreferredTmuxSessionName, TmuxUtilities } from '@/integrations/tmux'; import { AGENTS } from '@/backends/catalog'; import { DEFAULT_CATALOG_AGENT_ID } from '@/backends/types'; @@ -67,8 +67,8 @@ export async function startHappyHeadlessInTmux(argv: string[]): Promise<void> { tmuxTarget, ]; - const inv = buildHappyCliSubprocessInvocation([...childArgs, ...terminalRuntimeArgs]); - const commandTokens = [inv.runtime, ...inv.argv]; + const launchSpec = buildHappyCliSubprocessLaunchSpec([...childArgs, ...terminalRuntimeArgs]); + const commandTokens = [launchSpec.filePath, ...launchSpec.args]; const tmux = new TmuxUtilities(resolvedSessionName); const result = await tmux.spawnInTmux( @@ -78,7 +78,7 @@ export async function startHappyHeadlessInTmux(argv: string[]): Promise<void> { windowName, cwd: process.cwd(), }, - { ...buildWindowEnv(), ...(inv.env ?? {}) }, + { ...buildWindowEnv(), ...(launchSpec.env ?? {}) }, ); if (!result.success) { diff --git a/apps/cli/src/ui/auth.ts b/apps/cli/src/ui/auth.ts index 051a6387a..272d3f026 100644 --- a/apps/cli/src/ui/auth.ts +++ b/apps/cli/src/ui/auth.ts @@ -292,7 +292,7 @@ async function doWebAuth(params: Readonly<{ keypair: tweetnacl.BoxKeyPair; claim } // I changed this to always show the URL because we got a report from - // someone running happy inside a devcontainer that they saw the + // someone running happy inside the dev-box container image that they saw the // "Complete authentication in your browser window." but nothing opened. // https://github.com/slopus/happy/issues/19 console.log('\nIf the browser did not open, please copy and paste this URL:'); diff --git a/apps/cli/src/ui/logger.test.ts b/apps/cli/src/ui/logger.test.ts index e1eac0ece..09923e709 100644 --- a/apps/cli/src/ui/logger.test.ts +++ b/apps/cli/src/ui/logger.test.ts @@ -44,6 +44,19 @@ describe('logger.debugLargeJson', () => { expect(content).toContain('[TEST] debugLargeJson'); }); + it('writes Error objects with message/stack instead of "{}" when DEBUG is set', async () => { + process.env.DEBUG = '1'; + + const { logger } = (await import('@/ui/logger')) as typeof import('@/ui/logger'); + + logger.debug('[TEST] error serialization', new Error('boom')); + + expect(existsSync(logger.getLogPath())).toBe(true); + const content = readFileSync(logger.getLogPath(), 'utf8'); + expect(content).toContain('[TEST] error serialization'); + expect(content).toContain('boom'); + }); + it('creates logs dir on demand when writing the first debug entry', async () => { process.env.DEBUG = '1'; diff --git a/apps/cli/src/ui/logger.ts b/apps/cli/src/ui/logger.ts index bcced6a80..353f82455 100644 --- a/apps/cli/src/ui/logger.ts +++ b/apps/cli/src/ui/logger.ts @@ -187,9 +187,15 @@ class Logger { body: JSON.stringify({ timestamp: new Date().toISOString(), level, - message: `${message} ${args.map(a => - typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a) - ).join(' ')}`, + message: `${message} ${args.map(a => { + if (a instanceof Error) return a.stack || a.message + if (typeof a === 'object') { + // Check for Error-like objects (cross-realm Errors where instanceof fails) + if (a && 'stack' in (a as object)) return (a as Error).stack || String(a) + try { return JSON.stringify(a, null, 2) } catch { return String(a) } + } + return String(a) + }).join(' ')}`, source: 'cli', platform: process.platform }) @@ -200,9 +206,17 @@ class Logger { } private logToFile(prefix: string, message: string, ...args: unknown[]): void { - const logLine = `${prefix} ${message} ${args.map(arg => - typeof arg === 'string' ? arg : JSON.stringify(arg) - ).join(' ')}\n` + const logLine = `${prefix} ${message} ${args.map(arg => { + if (typeof arg === 'string') return arg + if (arg instanceof Error) return arg.stack || arg.message + try { + return JSON.stringify(arg) + } catch { + // Circular references, cross-realm Error objects, BigInt, etc. + if (arg && typeof arg === 'object' && 'stack' in arg) return (arg as Error).stack || String(arg) + return String(arg) + } + }).join(' ')}\n` // Send to remote server if configured if (this.dangerouslyUnencryptedServerLoggingUrl) { diff --git a/apps/cli/src/utils/processEnv/stripNestedSessionDetectionEnv.test.ts b/apps/cli/src/utils/processEnv/stripNestedSessionDetectionEnv.test.ts new file mode 100644 index 000000000..1299e1e17 --- /dev/null +++ b/apps/cli/src/utils/processEnv/stripNestedSessionDetectionEnv.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { stripNestedSessionDetectionEnv } from './stripNestedSessionDetectionEnv'; + +describe('stripNestedSessionDetectionEnv', () => { + it('removes nested session detection env vars', () => { + const input: NodeJS.ProcessEnv = { + PATH: '/bin', + CLAUDECODE: '1', + CLAUDE_CODE_ENTRYPOINT: 'parent', + }; + + const output = stripNestedSessionDetectionEnv(input); + + expect(output.PATH).toBe('/bin'); + expect(output.CLAUDECODE).toBeUndefined(); + expect(output.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + }); + + it('does not mutate the input object', () => { + const input: NodeJS.ProcessEnv = { CLAUDECODE: '1' }; + stripNestedSessionDetectionEnv(input); + expect(input.CLAUDECODE).toBe('1'); + }); +}); diff --git a/apps/cli/src/utils/processEnv/stripNestedSessionDetectionEnv.ts b/apps/cli/src/utils/processEnv/stripNestedSessionDetectionEnv.ts new file mode 100644 index 000000000..da3541ca5 --- /dev/null +++ b/apps/cli/src/utils/processEnv/stripNestedSessionDetectionEnv.ts @@ -0,0 +1,16 @@ +/** + * Some tools/IDEs set environment variables to detect "nested sessions". + * If those variables leak into spawned processes, child tools may refuse to + * start (e.g. to prevent recursive/nested execution). + * + * Today we strip Claude Code's nested-session detection variables. + * Keep this helper backend-agnostic since the daemon and multiple backends + * spawn child processes. + */ +export function stripNestedSessionDetectionEnv(input: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const out: NodeJS.ProcessEnv = { ...input }; + delete out.CLAUDECODE; + delete out.CLAUDE_CODE_ENTRYPOINT; + return out; +} + diff --git a/apps/cli/src/utils/spawnHappyCLI.entrypointMissing.test.ts b/apps/cli/src/utils/spawnHappyCLI.entrypointMissing.test.ts index e8f03246c..249203d9b 100644 --- a/apps/cli/src/utils/spawnHappyCLI.entrypointMissing.test.ts +++ b/apps/cli/src/utils/spawnHappyCLI.entrypointMissing.test.ts @@ -54,4 +54,22 @@ describe('buildHappyCliSubprocessInvocation (missing entrypoint)', () => { expect(invocation.env?.TSX_TSCONFIG_PATH).toEqual(expect.stringMatching(/[\\/]apps[\\/]cli[\\/]tsconfig\.json$/)); expect(process.env.TSX_TSCONFIG_PATH).toBeUndefined(); }); + + it('falls back to current bun script path when dist entrypoint is missing in bundled runtime', async () => { + vi.resetModules(); + vi.stubEnv('HAPPIER_CLI_SUBPROCESS_RUNTIME', 'bun'); + vi.stubEnv('HAPPIER_CLI_SUBPROCESS_ENTRYPOINT', '/$bunfs/dist/index.mjs'); + + const originalArgv = [...process.argv]; + process.argv = ['bun', '/$bunfs/root/happier-darwin-arm64', 'daemon', 'start-sync']; + + try { + const mod = (await import('./spawnHappyCLI')) as typeof import('./spawnHappyCLI'); + const invocation = mod.buildHappyCliSubprocessInvocation(['daemon', 'start-sync']); + expect(invocation.runtime).toBe('bun'); + expect(invocation.argv).toEqual(['/$bunfs/root/happier-darwin-arm64', 'daemon', 'start-sync']); + } finally { + process.argv = originalArgv; + } + }); }); diff --git a/apps/cli/src/utils/spawnHappyCLI.ts b/apps/cli/src/utils/spawnHappyCLI.ts index 9ab5f3552..c75c03c70 100644 --- a/apps/cli/src/utils/spawnHappyCLI.ts +++ b/apps/cli/src/utils/spawnHappyCLI.ts @@ -50,13 +50,12 @@ */ import { spawn, SpawnOptions, type ChildProcess } from 'child_process'; -import { join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { projectPath } from '@/projectPath'; import { logger } from '@/ui/logger'; import { existsSync } from 'node:fs'; import { isBun } from './runtime'; import { createRequire } from 'node:module'; -import { dirname } from 'node:path'; function getSubprocessRuntime(): 'node' | 'bun' { const override = process.env.HAPPIER_CLI_SUBPROCESS_RUNTIME; @@ -124,11 +123,56 @@ function shouldAllowDevTsxFallback(): boolean { return true; } -export function buildHappyCliSubprocessInvocation(args: string[]): { - runtime: 'node' | 'bun'; +export type HappyCliSubprocessRuntime = 'node' | 'bun'; + +export type HappyCliSubprocessInvocation = { + runtime: HappyCliSubprocessRuntime; argv: string[]; env?: Record<string, string>; -} { +}; + +export type HappyCliSubprocessLaunchSpec = { + runtime: HappyCliSubprocessRuntime; + filePath: string; + args: string[]; + env?: Record<string, string>; +}; + +function isRuntimeExecutablePath(pathLike: string): boolean { + const base = basename(String(pathLike ?? '').trim()).toLowerCase(); + return base === 'node' || base === 'node.exe' || base === 'bun' || base === 'bun.exe'; +} + +function isCurrentProcessSelfContainedBinary(): boolean { + const execPath = String(process.execPath ?? '').trim(); + if (!execPath) return false; + return !isRuntimeExecutablePath(execPath); +} + +function resolveCurrentProcessBundledScriptPath(): string | null { + const scriptPath = String(process.argv[1] ?? '').trim(); + if (!scriptPath) return null; + const normalized = scriptPath.replaceAll('\\', '/'); + if (normalized.startsWith('/$bunfs/root/')) return scriptPath; + if (!existsSync(scriptPath)) return null; + const lowered = normalized.toLowerCase(); + const base = basename(lowered); + if (base.includes('happier')) return scriptPath; + if (base === 'index.mjs' && (lowered.includes('/@happier-dev/cli/') || lowered.includes('/happier/'))) { + return scriptPath; + } + return null; +} + +function resolveSubprocessRuntimeExecutable(runtime: HappyCliSubprocessRuntime): string { + // Prefer the currently-running runtime binary when possible. This avoids PATH + // issues on Windows (and GUI-launched shells) where `node`/`bun` may not resolve. + if (runtime === 'node' && !isBun()) return process.execPath; + if (runtime === 'bun' && isBun()) return process.execPath; + return runtime; +} + +export function buildHappyCliSubprocessInvocation(args: string[]): HappyCliSubprocessInvocation { const entrypoint = resolveSubprocessEntrypoint(); const runtime = getSubprocessRuntime(); @@ -159,6 +203,15 @@ export function buildHappyCliSubprocessInvocation(args: string[]): { }; } } + if (runtime === 'bun') { + const bundledScriptPath = resolveCurrentProcessBundledScriptPath(); + if (bundledScriptPath) { + return { runtime: 'bun', argv: [bundledScriptPath, ...args] }; + } + if (isCurrentProcessSelfContainedBinary()) { + return { runtime: 'bun', argv: [...args] }; + } + } const errorMessage = `Entrypoint ${entrypoint} does not exist`; logger.debug(`[SPAWN HAPPIER CLI] ${errorMessage}`); throw new Error(errorMessage); @@ -168,6 +221,16 @@ export function buildHappyCliSubprocessInvocation(args: string[]): { return { runtime, argv }; } +export function buildHappyCliSubprocessLaunchSpec(args: string[]): HappyCliSubprocessLaunchSpec { + const invocation = buildHappyCliSubprocessInvocation(args); + return { + runtime: invocation.runtime, + filePath: resolveSubprocessRuntimeExecutable(invocation.runtime), + args: invocation.argv, + env: invocation.env, + }; +} + /** * Spawn the Happier CLI with the given arguments in a cross-platform way. * @@ -194,19 +257,9 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child const fullCommand = `happier ${args.join(' ')}`; logger.debug(`[SPAWN HAPPIER CLI] Spawning: ${fullCommand} in ${directory}`); - const { runtime, argv, env } = buildHappyCliSubprocessInvocation(args); - const spawnOptions: SpawnOptions = env - ? { ...options, env: { ...(options.env ?? process.env), ...env } } + const launchSpec = buildHappyCliSubprocessLaunchSpec(args); + const spawnOptions: SpawnOptions = launchSpec.env + ? { ...options, env: { ...(options.env ?? process.env), ...launchSpec.env } } : options; - - // Prefer the currently-running runtime binary when possible. This avoids PATH - // issues on Windows (and GUI-launched shells) where `node` may not resolve. - const runtimeExecutable = - runtime === 'node' && !isBun() - ? process.execPath - : runtime === 'bun' && isBun() - ? process.execPath - : runtime; - - return spawn(runtimeExecutable, argv, spawnOptions); + return spawn(launchSpec.filePath, launchSpec.args, spawnOptions); } diff --git a/apps/docs/content/docs/deployment/docker.mdx b/apps/docs/content/docs/deployment/docker.mdx index 824963640..bc8063373 100644 --- a/apps/docs/content/docs/deployment/docker.mdx +++ b/apps/docs/content/docs/deployment/docker.mdx @@ -9,12 +9,29 @@ Use `/apps/server/.env.example` as your base env template and load the same valu ## Prebuilt images (recommended) -We publish preview Docker images on Docker Hub: +We publish preview Docker images on Docker Hub (recommended): ```bash docker pull happierdev/relay-server:preview ``` +We also publish the same images to GitHub Container Registry (GHCR): + +```bash +docker pull ghcr.io/happier-dev/relay-server:preview +``` + +Published images: + +- `relay-server`: Happier Server + embedded web UI (this page) +- `dev-box`: Happier CLI + daemon in Docker (see: [Dev box](../development/devcontainers)) + +Tags: + +- `:preview` (floating preview tag) +- `:preview-<short-sha>` (immutable preview build) +- `:stable` / `:latest` (stable channel, when enabled) + The `relay-server` image defaults to the **light flavor + SQLite**, with all data stored under `/data`. Mount it as a volume so upgrades and restarts are safe. ```bash @@ -23,6 +40,12 @@ docker run --rm -p 3005:3005 \ happierdev/relay-server:preview ``` +Configuration docs: + +- Server env var reference: [Environment variables](./env) +- Server auth policy (GitHub/OIDC/anonymous, etc): [Server Auth](../server/auth) +- If you’re running a headless machine and want to pair/authenticate without a browser: [Daemon auth (headless)](../clients/daemon#headless-authentication-no-tty) + Notes: - This Docker image runs the Happier Server directly (a self-host-friendly default config). @@ -31,6 +54,105 @@ Notes: - Serve UI under `/ui`: set `HAPPIER_SERVER_UI_PREFIX=/ui`. - It does not install a managed host service. Upgrades happen when the container is restarted after pulling a new image tag. +### Prebuilt image: use Postgres (full flavor) + +The `relay-server` image is built from the same server code and can run full flavor too — you just override the defaults. + +Example `docker-compose.yml` (Postgres + local files backend): + +```yaml +services: + db: + image: postgres:17 + environment: + POSTGRES_DB: happier + POSTGRES_USER: happier + POSTGRES_PASSWORD: change-me + volumes: + - pgdata:/var/lib/postgresql/data + + relay: + image: happierdev/relay-server:preview + ports: + - "3005:3005" + environment: + PORT: "3005" + HAPPIER_SERVER_FLAVOR: full + HAPPIER_DB_PROVIDER: postgres + DATABASE_URL: postgresql://happier:change-me@db:5432/happier + HANDY_MASTER_SECRET: change-me-to-a-long-random-string + HAPPIER_FILES_BACKEND: local + HAPPIER_SERVER_LIGHT_DATA_DIR: /data/happier + volumes: + - happier-relay-data:/data/happier + depends_on: + - db + +volumes: + pgdata: + happier-relay-data: +``` + +For S3/Minio instead of local files, set `HAPPIER_FILES_BACKEND=s3` and the `S3_*` env vars (see [Environment variables](./env)). + +## Optional: expose a relay securely with Tailscale + +If you want to access your self-hosted relay from a phone or remote machine without opening inbound ports, we recommend using **Tailscale** as a sidecar and publishing the server via **Tailscale Serve**. + +This approach: + +- avoids public ingress and TLS setup +- gives you a stable HTTPS URL inside your tailnet +- keeps the Happier server image unprivileged (Tailscale runs in its own container) + +Minimal `docker-compose.yml` example (preview channel): + +```yaml +services: + relay: + image: happierdev/relay-server:preview + environment: + PORT: "3005" + # Optional: + # Disable UI serving: + # HAPPIER_SERVER_UI_DIR: "" + volumes: + - happier-relay-data:/data + + tailscale: + image: tailscale/tailscale:stable + network_mode: service:relay + cap_add: + - NET_ADMIN + - SYS_MODULE + devices: + - /dev/net/tun:/dev/net/tun + environment: + TS_AUTHKEY: ${TS_AUTHKEY} + TS_HOSTNAME: happier-relay + TS_STATE_DIR: /var/lib/tailscale + volumes: + - tailscale-state:/var/lib/tailscale + command: > + sh -lc ' + tailscaled --state=${TS_STATE_DIR}/tailscaled.state & + sleep 2 + tailscale up --authkey="${TS_AUTHKEY}" --hostname="${TS_HOSTNAME}" --accept-dns=false + tailscale serve --bg --https=443 http://127.0.0.1:3005 + tail -f /dev/null + ' + +volumes: + happier-relay-data: + tailscale-state: +``` + +Notes: + +- Use an **ephemeral**, **one-time** pre-auth key for `TS_AUTHKEY` when possible. +- `tailscale serve` publishes the Happier UI + API (including WebSockets) behind the Tailscale HTTPS endpoint. +- If your environment can’t use `/dev/net/tun` (some restricted hosts), fall back to a normal reverse proxy (Caddy/Traefik/nginx) or run Tailscale on the host OS and proxy to the container. + ## Build images This repo includes a top-level `Dockerfile` with multiple targets. diff --git a/apps/docs/content/docs/deployment/env.mdx b/apps/docs/content/docs/deployment/env.mdx index 7254837d7..a3e50c949 100644 --- a/apps/docs/content/docs/deployment/env.mdx +++ b/apps/docs/content/docs/deployment/env.mdx @@ -47,6 +47,66 @@ Then: - `METRICS_ENABLED` (default `true`) - `METRICS_PORT` (default `9090`) +## Networking / reverse proxy + +- `HAPPIER_SERVER_TRUST_PROXY` (optional) + Controls whether the server trusts `X-Forwarded-*` headers for client IP / protocol when running behind a reverse proxy. + - unset: use framework defaults (do not trust forwarded headers) + - `true` / `1`: trust all proxy hops (only safe if the server is not directly reachable) + - `false` / `0`: never trust forwarded headers + - `<number>`: trust the last N hops (recommended for typical single-proxy setups: `1`) + +## API rate limits + +Rate limiting is implemented via `@fastify/rate-limit`. Each `*_WINDOW` value accepts human-readable durations like `30 seconds`, `1 minute`, `5 minutes`. + +Global controls: + +- `HAPPIER_API_RATE_LIMITS_ENABLED` (default `true`) + Set to `0`/`false` to disable all API rate limits. +- `HAPPIER_API_RATE_LIMITS_GLOBAL_MAX` (default `0`) + Optional global limit applied to all routes. Set to `0` to disable. +- `HAPPIER_API_RATE_LIMITS_GLOBAL_WINDOW` (default `1 minute`) +- `HAPPIER_API_RATE_LIMIT_KEY_MODE` (default `auth-or-ip`) + Controls how rate limits are keyed: + - `auth-or-ip`: prefer a hashed `Authorization` header, fall back to client IP + - `auth-only`: always key by `Authorization` (unauthenticated requests share a single bucket) + - `ip-only`: always key by client IP +- `HAPPIER_API_RATE_LIMIT_CLIENT_IP_SOURCE` (default `fastify`) + Controls how the client IP is derived when IP-keying is used: + - `fastify`: use the framework-derived `request.ip` (recommended; configure `HAPPIER_SERVER_TRUST_PROXY` behind a reverse proxy) + - `x-forwarded-for`: derive from `X-Forwarded-For` (only safe when the server is not directly reachable) + - `x-real-ip`: derive from `X-Real-Ip` (only safe when the server is not directly reachable) + +Hot endpoints (set `*_MAX=0` to disable a specific limit): + +- `HAPPIER_SESSION_MESSAGES_RATE_LIMIT_MAX` (default `600`) +- `HAPPIER_SESSION_MESSAGES_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_SESSION_MESSAGES_BY_LOCAL_ID_RATE_LIMIT_MAX` (default `600`) +- `HAPPIER_SESSION_MESSAGES_BY_LOCAL_ID_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_SESSIONS_LIST_RATE_LIMIT_MAX` (default `300`) +- `HAPPIER_SESSIONS_LIST_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_CHANGES_RATE_LIMIT_MAX` (default `600`) +- `HAPPIER_CHANGES_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_FEATURES_RATE_LIMIT_MAX` (default `120`) +- `HAPPIER_FEATURES_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_MACHINES_RATE_LIMIT_MAX` (default `300`) +- `HAPPIER_MACHINES_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_ARTIFACTS_RATE_LIMIT_MAX` (default `300`) +- `HAPPIER_ARTIFACTS_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_FEED_RATE_LIMIT_MAX` (default `300`) +- `HAPPIER_FEED_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_KV_LIST_RATE_LIMIT_MAX` (default `600`) +- `HAPPIER_KV_LIST_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_ACCOUNT_PROFILE_RATE_LIMIT_MAX` (default `300`) +- `HAPPIER_ACCOUNT_PROFILE_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_ACCOUNT_SETTINGS_RATE_LIMIT_MAX` (default `300`) +- `HAPPIER_ACCOUNT_SETTINGS_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_SESSION_PENDING_RATE_LIMIT_MAX` (default `600`) +- `HAPPIER_SESSION_PENDING_RATE_LIMIT_WINDOW` (default `1 minute`) +- `HAPPIER_SESSION_PENDING_MATERIALIZE_RATE_LIMIT_MAX` (default `120`) +- `HAPPIER_SESSION_PENDING_MATERIALIZE_RATE_LIMIT_WINDOW` (default `1 minute`) + ## Storage backends - `S3_HOST` @@ -102,14 +162,39 @@ For policy behavior and examples, see [Server Auth](/docs/server/auth). - `AUTH_OFFBOARDING_ENABLED` - `AUTH_OFFBOARDING_INTERVAL_SECONDS` - `AUTH_OFFBOARDING_STRICT` -- `AUTH_RECOVERY_PROVIDER_RESET_ENABLED` -- `AUTH_UI_AUTO_REDIRECT` -- `AUTH_UI_AUTO_REDIRECT_PROVIDER_ID` -- `AUTH_UI_RECOVERY_KEY_REMINDER_ENABLED` +- `HAPPIER_FEATURE_AUTH_RECOVERY__PROVIDER_RESET_ENABLED` +- `HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED` +- `HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_PROVIDER_ID` +- `HAPPIER_FEATURE_AUTH_UI__RECOVERY_KEY_REMINDER_ENABLED` - `AUTH_PROVIDERS_CONFIG_PATH` - `AUTH_PROVIDERS_CONFIG_JSON` - `AUTH_ACCOUNT_DISABLED_TTL_SECONDS` +### Built-in key-challenge login route + +Happier’s default “device-key” signup/login flow uses `POST /v1/auth`. + +Server operators can disable this built-in login route (for example when all access should go through a provider like GitHub/OIDC). + +- `HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED` (default `1`) + +Note: if you disable the key-challenge route, ensure you have at least one viable provider configured (otherwise the server will fail fast at boot to prevent lockouts). + +### Keyless OAuth login (optional) + +Happier can use OAuth providers (GitHub/OIDC) as **keyless login methods**, meaning accounts can exist without device keys (intended for enterprise + plaintext storage deployments). + +Keyless OAuth is gated by feature env vars: + +- `HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED` (default `0`) +- `HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS` (CSV allowlist of provider ids, e.g. `github,okta`) +- `HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_AUTO_PROVISION` (default `0`) + +Notes: + +- `HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_AUTO_PROVISION=1` controls whether new keyless accounts can be created on first login. +- Keyless auto-provisioning requires keyless accounts + plaintext defaults to be enabled (see [Server Auth](/docs/server/auth) for the current prerequisites). + ### GitHub OAuth - `GITHUB_CLIENT_ID` @@ -127,6 +212,29 @@ For policy behavior and examples, see [Server Auth](/docs/server/auth). - `OAUTH_PENDING_TTL_SECONDS` - `OAUTH_STATE_TTL_SECONDS` +Note on “optional” web app URL env vars: +- `HAPPIER_WEBAPP_URL` / `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE` are only “optional” if you are using the hosted client at `https://app.happier.dev`. + - If you are self-hosting the web app, or using a local dev web UI, you must set one of these so the server can redirect back to your client after OAuth. + +### mTLS auth (enterprise) + +For full setup guidance, see [Server → mTLS Auth](/docs/server/auth-mtls). + +- `HAPPIER_FEATURE_AUTH_MTLS__ENABLED` (default `0`) +- `HAPPIER_FEATURE_AUTH_MTLS__MODE` (`forwarded` | `direct`) +- `HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION` (default `0`) +- `HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE` (`san_email` | `san_upn` | `subject_cn` | `fingerprint`) +- `HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS` (CSV) +- `HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS` (CSV; comma/newline separated issuer values; compared against the issuer CN extracted from the forwarded issuer string) +- `HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS` (default `0`) +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER` +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_UPN_HEADER` +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_SUBJECT_HEADER` +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_FINGERPRINT_HEADER` +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER` +- `HAPPIER_FEATURE_AUTH_MTLS__RETURN_TO_ALLOW_PREFIXES` (CSV) +- `HAPPIER_FEATURE_AUTH_MTLS__CLAIM_TTL_SECONDS` + ### GitHub eligibility restrictions - `AUTH_GITHUB_ALLOWED_USERS` @@ -144,6 +252,21 @@ For policy behavior and examples, see [Server Auth](/docs/server/auth). - `TERMINAL_AUTH_CLAIM_RETRY_WINDOW_SECONDS` - `VENDOR_TOKEN_MAX_LEN` +## Encryption / plaintext storage (E2EE opt-out) + +For an overview and operator guidance, see [Server → Encryption & plaintext storage](/docs/server/encryption). + +Server-wide storage policy: + +- `HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY` (default `required_e2ee`) + Values: `required_e2ee` | `optional` | `plaintext_only` + +Account-level opt-out (only meaningful when policy is `optional`): + +- `HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT` (default `0`) +- `HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE` (default `e2ee`) + Values: `e2ee` | `plain` + ## Friends / social - `HAPPIER_FEATURE_SOCIAL_FRIENDS__ENABLED` diff --git a/apps/docs/content/docs/deployment/index.mdx b/apps/docs/content/docs/deployment/index.mdx index f1f0f69ed..92447105e 100644 --- a/apps/docs/content/docs/deployment/index.mdx +++ b/apps/docs/content/docs/deployment/index.mdx @@ -14,6 +14,8 @@ This section describes how to deploy **your own Happier Server** (the sync backe - **Managed self-host runtime** (installer + service + rollback + optional auto-update). - See: [Self-host runtime (installer vs runner)](./self-host-runtime) +- **Run from a monorepo clone** (repo-local mode): use `yarn` from the repo root to run a stackless local stack, install a service, and configure Tailscale. + - See: [Run from a monorepo clone (repo-local mode)](./repo-local) - **Docker** (recommended starting point): build from this repo’s `Dockerfile` and run with Docker / Docker Compose. - See: [Docker](./docker) - **Configuration reference**: see [Environment variables](./env) for the complete server runtime env surface. diff --git a/apps/docs/content/docs/deployment/meta.json b/apps/docs/content/docs/deployment/meta.json index 7fe4b96e1..a6f2c12d1 100644 --- a/apps/docs/content/docs/deployment/meta.json +++ b/apps/docs/content/docs/deployment/meta.json @@ -2,6 +2,7 @@ "title": "Deployment", "pages": [ "index", + "repo-local", "self-host-runtime", "docker", "proxmox", diff --git a/apps/docs/content/docs/deployment/proxmox.mdx b/apps/docs/content/docs/deployment/proxmox.mdx index 4373fc3af..e2c6ac835 100644 --- a/apps/docs/content/docs/deployment/proxmox.mdx +++ b/apps/docs/content/docs/deployment/proxmox.mdx @@ -18,9 +18,39 @@ This guide explains how to deploy Happier on **Proxmox VE** using the community- 2. Copy the install command from the script page. 3. Paste it into the **Proxmox Shell** (host) and follow the prompts. -At the end, the installer prints the container IP and the local HTTP URL: +If the script is not available on `helper-scripts.com` yet, you can run the installer directly from our ProxmoxVE fork: -- `http://<container-ip>:3005` +```bash +tmp="$(mktemp)" \ + && curl -fsSL "https://raw.githubusercontent.com/happier-dev/ProxmoxVE/main/ct/happier.sh" -o "$tmp" \ + && bash "$tmp" \ + && rm -f "$tmp" +``` + +At the end, the installer prints: + +- Your container IP and local HTTP URL +- If applicable, your public HTTPS URL +- “Configure server” links (web + mobile deep link) so your app/web UI points to the right server +- Next steps to connect a terminal/daemon + +Local access: + +- `http://<container-ip>:3005` (LAN) + +If you select **Tailscale** during install, the stack typically binds to `loopback` and will not be reachable from your LAN IP. In that case, use the installer’s printed **HTTPS** URL instead. + +## Quick start (recommended) + +1. Run the Proxmox helper script install (above). +2. Choose a remote access option: + - **Tailscale** (recommended) if you want an HTTPS URL without opening ports. + - **Reverse proxy** if you already have Caddy/Nginx/Traefik. +3. Open the installer’s **Configure server** link on your phone (or `https://app.happier.dev`) and **sign in / create an account**. +4. On the computer where you want to run the daemon/terminal, run: + - `happier server add --server-url https://<your-server-url> --use` + - `happier auth login` +5. If you installed **Dev box**, follow the printed “connect daemon” step and restart the stack/service. ## After install: recommended onboarding flow @@ -29,7 +59,7 @@ Happier has two separate “things to connect”: - Your **app/web UI** needs to be configured to talk to **your server** - Your **terminal/daemon** needs to be connected to **your account on that server** -The Proxmox installer **does not** automatically authenticate for you. Instead, it prints a command you can run inside the container that will print **one-click links** (and a QR code) to guide you through the full flow. +The Proxmox installer **does not** automatically authenticate for you. It prints the exact commands/links you need for the full flow. ### 1) Ensure you have a public HTTPS URL (remote access) @@ -40,16 +70,85 @@ For remote access (phone / outside your LAN), the web UI requires **HTTPS** (sec - **Tailscale Serve** (recommended): easiest end-to-end HTTPS - **Reverse proxy** (Caddy / Nginx / Traefik): terminate TLS and proxy to the container -If you use a reverse proxy, make sure your stack knows its public URL (so links/QR codes embed the right server URL): +If you use a reverse proxy, the Proxmox installer will ask you for your public HTTPS URL and persist it in: + +- `/home/happier/.happier/stacks/main/env` as `HAPPIER_STACK_SERVER_URL=https://<your-domain>` + +If you need to change it later, edit the same file and restart the stack/service. + +#### Advanced: reverse proxy (Caddy / Nginx) + +For a reverse proxy setup, you typically terminate TLS on your proxy and forward to: + +- `http://<container-ip>:3005` + +Make sure your proxy supports **WebSockets** and forwards the usual “real URL” headers: + +- `Host` +- `X-Forwarded-Proto` +- `X-Forwarded-Host` +- `X-Forwarded-For` + +Also make sure the stack knows its public URL (for correct deep links/QR codes): + +- `HAPPIER_STACK_SERVER_URL=https://<your-domain>` in `/home/happier/.happier/stacks/main/env` + +Then restart: + +- `su - happier -c "/home/happier/.happier-stack/bin/hstack service restart --mode=system"` (if autostart enabled) +- `su - happier -c "/home/happier/.happier-stack/bin/hstack start --restart"` (manual start) + +**Caddy** (recommended for simplest HTTPS) + +`Caddyfile`: + +``` +happier.example.com { + reverse_proxy http://<container-ip>:3005 +} +``` -- Edit `/home/happier/.happier/stacks/main/env` and set: - - `HAPPIER_STACK_SERVER_URL=https://<your-domain>` +If you’re running Caddy on the Proxmox host or another machine, replace `<container-ip>` with your container’s LAN IP. + +**Nginx** (manual config) + +Server block snippet: + +```nginx +server { + listen 443 ssl http2; + server_name happier.example.com; + + # TLS config omitted (use certbot, or your existing TLS setup) + + location / { + proxy_pass http://<container-ip>:3005; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +If you use a UI-driven reverse proxy (e.g. Nginx Proxy Manager / Traefik), make sure “WebSocket support” is enabled and the upstream is `http://<container-ip>:3005`. ### 2) Configure your app/web UI to use your server -Inside the container, run: +Use the installer’s printed “Configure server” links first. They: + +- pre-fill your server URL +- help avoid “wrong server” confusion when you later connect a terminal/daemon -- `/home/happier/.happier-stack/bin/hstack auth login --context=selfhost --method=mobile --no-open` +If you missed the output, re-print the links from inside the container: + +- `/home/happier/.happier-stack/bin/hstack auth login --print --method=mobile --no-open` This will print: @@ -58,6 +157,11 @@ This will print: Open the configure link first, confirm, then sign in/create your account. +If you chose **not** to serve the web UI from your Proxmox container, use: + +- the **mobile app** (recommended), or +- the hosted web app at `https://app.happier.dev` and set the server there + ### 3) Connect the terminal/daemon Continue following the same `hstack auth login` output: @@ -73,8 +177,8 @@ If you installed **Dev box (server-light + daemon)**, the daemon is configured t After login completes: -- If autostart is enabled: `systemctl restart happier-stack` -- Otherwise: `/home/happier/.happier-stack/bin/hstack start --restart --bind=<loopback|lan> --no-browser` +- If autostart is enabled: `/home/happier/.happier-stack/bin/hstack service restart --mode=system` +- Otherwise: `/home/happier/.happier-stack/bin/hstack start --restart` ## Using Happier from another machine (common setup) @@ -83,8 +187,9 @@ If you installed **Server only** on Proxmox, you’ll typically run the **daemon On your laptop/desktop: - Save and switch to your self-hosted server: - - `happier auth login --server-url https://<your-server-url> --webapp-url https://<your-webapp-url>` - - Use `--webapp-url https://app.happier.dev` only if you intentionally use the hosted web UI. + - `happier server add --server-url https://<your-server-url> --use` +- Then connect your terminal/daemon: + - `happier auth login` This persists the server URL locally, then prints: @@ -107,7 +212,7 @@ For ongoing profile switching and multi-server usage, see [Features → Multi-se ### Autostart at boot -- **Yes**: Installs a systemd *system* unit inside the container (service name: `happier-stack.service`). +- **Yes**: Installs a systemd *system* service inside the container. - **No**: You start it manually. ### Remote access / HTTPS @@ -116,7 +221,38 @@ For ongoing profile switching and multi-server usage, see [Features → Multi-se - **Reverse proxy**: You terminate TLS yourself (Caddy / Nginx / Traefik / etc). - **None**: Local network only. -If you choose **Tailscale**, you can optionally paste a **Tailscale pre-auth key** (recommended: ephemeral, one-time). If you skip it, you can enroll later from inside the container. +If you choose **Tailscale**, you can optionally paste a **Tailscale pre-auth key** (recommended: ephemeral, one-time). If you skip it (or leave it blank), the installer will still install Tailscale and you can enroll later from inside the container. + +#### Tailscale enrollment (auto vs manual) + +Tailscale enrollment is optional and depends on your tailnet policy: + +- **Auto-enroll (optional):** provide a pre-auth key during install. If it works, the installer will attempt to bring the node online and enable Tailscale Serve. +- **Manual enroll (always works):** skip the key during install (or leave it blank), then run `tailscale up` later inside the container. + +If enrollment fails or a key is rejected/expired/revoked, the installer prints a “Login URL” (when available) and the exact manual commands to run. + +Manual enrollment commands (inside the container): + +```bash +tailscale up +tailscale set --operator=happier +su - happier -c "/home/happier/.happier-stack/bin/hstack tailscale enable" +su - happier -c "/home/happier/.happier-stack/bin/hstack tailscale url" +``` + +### HStack release channel + +The installer runs `hstack setup` via `npx` (npm). You can choose which HStack build you want: + +- **Stable** (recommended): installs `@happier-dev/stack@latest` +- **Preview**: installs `@happier-dev/stack@next` +- **Custom**: pin a version (or a different npm spec) + +Non-interactive overrides: + +- `HAPPIER_PVE_HSTACK_CHANNEL=stable|preview` (`preview` maps to npm `@next`) +- `HAPPIER_PVE_HSTACK_PACKAGE='@happier-dev/stack@<tag-or-version>'` (takes precedence) ## HTTPS guidance (important) @@ -133,8 +269,9 @@ Enter the container from the Proxmox host: Service management (if you enabled autostart): -- `systemctl status happier-stack` -- `journalctl -u happier-stack -f` +- `su - happier -c "/home/happier/.happier-stack/bin/hstack service status --mode=system"` +- `su - happier -c "/home/happier/.happier-stack/bin/hstack service logs --mode=system"` +- `su - happier -c "/home/happier/.happier-stack/bin/hstack service tail"` ## Dev box mode: connect the daemon (recommended) @@ -145,14 +282,14 @@ If you chose **Dev box (server-light + daemon)**, the server/UI can run before t 2. Switch to the `happier` user: - `su - happier` 3. Run mobile login (recommended): - - `/home/happier/.happier-stack/bin/hstack auth login --context=selfhost --no-open --method=mobile` + - `/home/happier/.happier-stack/bin/hstack auth login --no-open --method=mobile` After login succeeds: - If autostart is enabled, restart the service so it starts the daemon too: - - `systemctl restart happier-stack` -- Or start the daemon manually: - - `/home/happier/.happier-stack/bin/hstack daemon start` + - `/home/happier/.happier-stack/bin/hstack service restart --mode=system` +- Or start the stack manually: + - `/home/happier/.happier-stack/bin/hstack start --restart` You can verify status with: diff --git a/apps/docs/content/docs/deployment/repo-local.mdx b/apps/docs/content/docs/deployment/repo-local.mdx new file mode 100644 index 000000000..deb6ce529 --- /dev/null +++ b/apps/docs/content/docs/deployment/repo-local.mdx @@ -0,0 +1,175 @@ +--- +title: Run From A Monorepo Clone (Repo-Local Mode) +description: Use yarn commands from the Happier monorepo root to run a stackless local stack, install an OS service, and configure Tailscale. +--- + +If you cloned the Happier monorepo, you can run most day-to-day workflows directly from the repo root using `yarn` scripts. + +This mode is **repo-local** (stackless): it creates an isolated per-checkout stack under `~/.happier/stacks/<repo-stack>/...` and does **not** require you to pick a stack name. + +## Prerequisites + +- Node + Corepack enabled +- `yarn install` in the monorepo root + +## Quick Start (Dev) + +```bash +cd /path/to/happier +yarn dev +``` + +For the interactive terminal UI: + +```bash +yarn tui +``` + +With mobile (starts the dev stack with `--mobile` so the Expo dev-client QR flow is available): + +```bash +yarn tui:with-mobile +``` + +## Run The Happier CLI From Anywhere (Use This Repo Checkout) + +If you want `hstack` / `happier` commands you run from any terminal to execute from **this** clone (instead of a runtime install), +activate repo-local shims: + +```bash +yarn cli:activate +``` + +To also add `~/.happier-stack/bin` to your shell `PATH` automatically (edits shell config): + +```bash +yarn cli:activate:path +``` + +## Production-Like Start (Local) + +`yarn start` runs the local stack in a **prod-like** mode (server + daemon + optionally serving a prebuilt UI bundle). + +Before `yarn start`, build the static UI export: + +```bash +yarn build +yarn start +``` + +By default, `yarn build` writes the web UI export into your stack base dir: + +```text +~/.happier/stacks/<repo-stack>/ui +``` + +## OS Service (Autostart) + +You can install an OS service that runs `hstack start` in the background (launchd on macOS; systemd on Linux). + +Recommended (build UI then install service): + +```bash +yarn service:install +yarn service:enable +``` + +Other service commands: + +```bash +yarn service status +yarn service logs +yarn service tail +yarn service restart +yarn service:disable +yarn service:uninstall +``` + +Linux system service (advanced): + +```bash +yarn service:install:system +``` + +## Tailscale Serve (HTTPS Secure Context) + +If you use Tailscale, you can configure `tailscale serve` to expose your local stack over HTTPS. + +```bash +yarn tailscale:enable +yarn tailscale:url +``` + +Other Tailscale commands: + +```bash +yarn tailscale status +yarn tailscale:disable +``` + +## Logs + +Follow logs (defaults to `--component=auto` selection): + +```bash +yarn logs +``` + +Common streams (no extra flags needed): + +```bash +yarn logs:all +yarn logs:server +yarn logs:expo +yarn logs:ui +yarn logs:daemon +yarn logs:service +``` + +Advanced: pass arguments to `hstack logs` (Yarn v1 requires `--` to forward args to the underlying command): + +```bash +yarn logs -- --component=server --follow +yarn logs -- --component=expo --follow +yarn logs -- --component=daemon --lines 200 --no-follow +``` + +## Environment Variables (Per Repo-Local Stack) + +```bash +yarn env list +yarn env set KEY=VALUE +yarn env unset KEY +``` + +These edits are persisted in: + +```text +~/.happier/stacks/<repo-stack>/env +``` + +## Mobile + +Install the iOS release build (runs the full flow, no stack name required): + +```bash +yarn mobile:install +``` + +Other repo-local mobile workflows: + +```bash +yarn mobile +yarn mobile-dev-client +``` + +## Full Command List (Repo Root) + +The monorepo root `package.json` exposes these entrypoints (all repo-local): + +- `yarn dev`, `yarn start`, `yarn stop`, `yarn build`, `yarn tui` +- `yarn logs`, `yarn env`, `yarn auth`, `yarn daemon` +- `yarn service` (and `yarn service:*` convenience scripts) +- `yarn tailscale` (and `yarn tailscale:*` convenience scripts) +- `yarn mobile`, `yarn mobile:install`, `yarn mobile-dev-client` +- `yarn providers`, `yarn eas`, `yarn happier`, `yarn setup`, `yarn remote`, `yarn self-host`, `yarn menubar` diff --git a/apps/docs/content/docs/development/devcontainers.mdx b/apps/docs/content/docs/development/devcontainers.mdx index 972023612..ce2e9395d 100644 --- a/apps/docs/content/docs/development/devcontainers.mdx +++ b/apps/docs/content/docs/development/devcontainers.mdx @@ -1,16 +1,22 @@ --- -title: Dev Containers -description: Run Happier CLI + daemon inside a container, and pair it to your account without opening a browser. +title: Dev box +description: Run Happier CLI + daemon inside a container (dev-box), and pair it to your account without opening a browser. --- -This page explains how to run Happier inside a container (for example: a remote VM or a disposable “dev container”) and authenticate it **from your local machine**. +This page explains how to run Happier inside a container (for example: a remote VM or a disposable “dev box”) and authenticate it **from your local machine**. -## Devcontainer image +## Dev box image -We publish a preview devcontainer image on Docker Hub: +We publish a preview dev box image on Docker Hub: ```bash -docker pull happierdev/dev-container:preview +docker pull happierdev/dev-box:preview +``` + +We also publish the same image to GitHub Container Registry (GHCR): + +```bash +docker pull ghcr.io/happier-dev/dev-box:preview ``` The image includes: @@ -18,6 +24,8 @@ The image includes: - `happier` CLI - optional provider CLIs (install on first boot via env) +Note: “dev box” is our Docker image name — it’s not tied to any specific editor “devcontainer” feature. + ### Optional: install provider CLIs on first boot Set `HAPPIER_PROVIDER_CLIS` to install provider CLIs into the container user’s npm prefix: @@ -25,7 +33,7 @@ Set `HAPPIER_PROVIDER_CLIS` to install provider CLIs into the container user’s ```bash docker run --rm -it \ -e HAPPIER_PROVIDER_CLIS=claude,codex \ - happierdev/dev-container:preview + happierdev/dev-box:preview ``` Supported values today: `claude`, `codex`. @@ -37,7 +45,7 @@ Persist `~/.happier` so credentials, machine IDs, logs, etc. survive container r ```bash docker run --rm -it \ -v happier-home:/home/happier/.happier \ - happierdev/dev-container:preview + happierdev/dev-box:preview ``` ## Pair a remote container/host from your local CLI (no web UI) diff --git a/apps/docs/content/docs/development/testing.mdx b/apps/docs/content/docs/development/testing.mdx index 6f13f1681..c3d02173b 100644 --- a/apps/docs/content/docs/development/testing.mdx +++ b/apps/docs/content/docs/development/testing.mdx @@ -75,6 +75,20 @@ Core E2E slow naming convention: - All other `suites/core-e2e/**/*.test.ts` files run in `test:e2e:core:fast`. - `test:e2e` still runs the full core suite (fast + slow together). +### 4b) UI E2E (Playwright) + +Browser-driven end-to-end tests against the Expo web UI, using the same server-light + daemon harness used by core E2E. + +**Root command:** +```bash +yarn test:e2e:ui +``` + +Notes: +- These tests run Playwright against `expo start --web` and require Playwright browsers to be installed (see Playwright install docs). +- UI E2E should stay small and focus on flows that are uniquely UI-driven (auth/login/connect, navigation, and key cross-app wiring). Lower-level server/daemon invariants belong in core E2E. +- If you suspect stale Metro transforms, you can opt into clearing the Expo/Metro cache with `HAPPIER_E2E_EXPO_CLEAR=1` (default is off because `--clear` can occasionally crash Metro). + ### 5) Providers Provider contract/baseline suites in `packages/tests` (real provider CLIs). diff --git a/apps/docs/content/docs/features/friends.mdx b/apps/docs/content/docs/features/friends.mdx index 1266cd527..6c304f1d6 100644 --- a/apps/docs/content/docs/features/friends.mdx +++ b/apps/docs/content/docs/features/friends.mdx @@ -81,6 +81,7 @@ Required to enable GitHub connect: Optional: - `HAPPIER_WEBAPP_URL` (default `https://app.happier.dev`): base URL/scheme for the client. The server redirects to `${HAPPIER_WEBAPP_URL}/oauth/github` after the server callback completes. + - This is only “optional” if you are using the hosted client at `https://app.happier.dev`. If you are self-hosting the web app (or using a local dev web UI), set `HAPPIER_WEBAPP_URL` (or `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE`) so OAuth can return to your client. - `GITHUB_STORE_ACCESS_TOKEN` (`true`/`false`, default `false`): whether to persist GitHub access tokens on the server (Friends does not require this). If you enforce org membership via `AUTH_GITHUB_ORG_MEMBERSHIP_SOURCE=oauth_user_token` (membership checks via user OAuth token mode), token storage is required for offboarding checks. See `/docs/deployment/env#github-org-membership-source-recommended-github-app`. - `OAUTH_PENDING_TTL_SECONDS` (default `600`, min `60`, max `3600`): how long a “pending OAuth finalize” (e.g. waiting for username selection) can be finalized. - Legacy alias: `GITHUB_OAUTH_PENDING_TTL_SECONDS` (if `OAUTH_PENDING_TTL_SECONDS` is unset). @@ -90,6 +91,7 @@ Optional: 1. In GitHub, go to **Settings → Developer settings → OAuth Apps → New OAuth App**. 2. Set **Authorization callback URL** to your server callback endpoint: - `https://YOUR_SERVER/v1/oauth/github/callback` + - You do **not** need GitHub’s “Device Flow / device authentication flow”. Happier uses the standard browser redirect OAuth flow, so you can leave “Enable Device Flow” **disabled**. 3. Copy the **Client ID** and **Client secret** into your server environment as `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`. 4. Set `GITHUB_REDIRECT_URL` to the same callback URL you registered above. 5. (Self-hosting) Set `HAPPIER_WEBAPP_URL` to the URL/scheme that should receive the post-OAuth redirect. diff --git a/apps/docs/content/docs/hstack/isolated-linux-vm.mdx b/apps/docs/content/docs/hstack/isolated-linux-vm.mdx index 4441c2046..673dd3f7f 100644 --- a/apps/docs/content/docs/hstack/isolated-linux-vm.mdx +++ b/apps/docs/content/docs/hstack/isolated-linux-vm.mdx @@ -17,12 +17,12 @@ This avoids Docker/container UX issues (browser opening, Expo networking, file w brew install lima ``` -### 1b) Automated E2E smoke test (optional) +### 1b) Automated smoke test (optional) -On your macOS host (this repo): +On your macOS host (this repo, from `apps/stack/`): ```bash -./scripts/provision/macos-lima-hstack-e2e.sh happy-e2e +./scripts/provision/macos-lima-hstack-smoke.sh happy-e2e ``` What it validates (best-effort): @@ -33,18 +33,18 @@ What it validates (best-effort): Notes: - This runs inside the VM (Linux) and uses `npx` by default. -- You can pin the version under test: `HSTACK_VERSION=0.6.14 ./scripts/provision/macos-lima-hstack-e2e.sh happy-e2e`. +- You can pin the version under test: `HSTACK_VERSION=0.6.14 ./scripts/provision/macos-lima-hstack-smoke.sh happy-e2e`. - If you’re testing a fork, you can point the runner at your fork’s raw scripts: - `HSTACK_RAW_BASE=https://raw.githubusercontent.com/<owner>/<repo>/<ref>/apps/stack ./scripts/provision/macos-lima-hstack-e2e.sh happy-e2e`. + `HSTACK_RAW_BASE=https://raw.githubusercontent.com/<owner>/<repo>/<ref>/apps/stack ./scripts/provision/macos-lima-hstack-smoke.sh happy-e2e`. - If you’re testing unpublished local changes, copy a packed tarball into the VM and run: - `HSTACK_TGZ=./happier-dev-stack-*.tgz /tmp/linux-ubuntu-e2e.sh`. + `HSTACK_TGZ=./happier-dev-stack-*.tgz /tmp/linux-ubuntu-hstack-smoke.sh`. ### 2) Create + configure a VM (recommended script) -On your macOS host (this repo): +On your macOS host (this repo, from `apps/stack/`): ```bash -./scripts/provision/macos-lima-happy-vm.sh happy-test +./scripts/provision/macos-lima-vm.sh happy-test ``` This creates the VM if needed and configures **localhost port forwarding** for the port ranges used by our VM defaults. @@ -54,7 +54,7 @@ It also sets a higher default VM memory size (to avoid Expo/Metro getting OOM-ki Override if needed: ```bash -LIMA_MEMORY=12GiB ./scripts/provision/macos-lima-happy-vm.sh happy-test +LIMA_MEMORY=12GiB ./scripts/provision/macos-lima-vm.sh happy-test ``` Port ranges note: @@ -136,9 +136,9 @@ limactl shell happy-pr Inside the VM: ```bash -curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-review-pr.sh -o /tmp/linux-ubuntu-review-pr.sh \ - && chmod +x /tmp/linux-ubuntu-review-pr.sh \ - && /tmp/linux-ubuntu-review-pr.sh +curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-provision.sh -o /tmp/linux-ubuntu-provision.sh \ + && chmod +x /tmp/linux-ubuntu-provision.sh \ + && /tmp/linux-ubuntu-provision.sh --profile=happier ``` ### 3b) (Optional) Run the hstack dev setup wizard diff --git a/apps/docs/content/docs/security/encryption.mdx b/apps/docs/content/docs/security/encryption.mdx index 28bf48abb..7b191ed46 100644 --- a/apps/docs/content/docs/security/encryption.mdx +++ b/apps/docs/content/docs/security/encryption.mdx @@ -9,3 +9,8 @@ This doc is intentionally high-level: - Encrypted payloads are uploaded and synced. - Sharing requires distributing data keys to authorized users (still encrypted end-to-end). +## Plaintext storage (encryption opt-out) + +Some deployments may choose to store **new** session content as plaintext on the server (no E2EE for storage). + +This is a server-operator configuration. See: [Server → Encryption & plaintext storage](../server/encryption). diff --git a/apps/docs/content/docs/server/auth-custom-providers.mdx b/apps/docs/content/docs/server/auth-custom-providers.mdx new file mode 100644 index 000000000..6c3ee19d6 --- /dev/null +++ b/apps/docs/content/docs/server/auth-custom-providers.mdx @@ -0,0 +1,64 @@ +--- +title: Custom Auth Providers +description: How to add custom login providers (including enterprise forks) without duplicating core auth logic. +--- + +Happier’s server supports a **provider-module registry** so new authentication mechanisms can be added without rewriting core route wiring or `/v1/features` advertising. + +Key idea: + +- An auth mechanism is an **auth method**. +- A method can support multiple **actions** (for example: `login`, `provision`, `connect`). +- The server advertises methods centrally via `capabilities.auth.methods[]`. + +## Provider modules (server) + +Provider modules live under: + +- `apps/server/sources/app/auth/providers/*` + +The registry is assembled in: + +- `apps/server/sources/app/auth/providers/providerModules.ts` + +Each module can contribute one or more **facets**: + +- `oauth`: OAuth redirect/callback flow wiring (e.g. GitHub/OIDC) +- `identity`: identity linking + offboarding eligibility enforcement +- `auth`: provider status/capabilities surfaced under `capabilities.auth.providers` + +Non-OAuth auth methods (like mTLS) are registered separately via the **auth-method registry**: + +- `apps/server/sources/app/auth/methods/registry.ts` +- `apps/server/sources/app/auth/methods/modules/*` + +## Adding a custom auth method (enterprise fork) + +Auth methods are the right extension point for login mechanisms that are **not** the built-in key-challenge route and are **not** implemented via OAuth/OIDC config. + +1. Create a new auth-method module, for example: + - `apps/server/sources/app/auth/methods/modules/acmeAuthMethodModule.ts` + +2. Implement an `AuthMethodModule`: + - Types: `apps/server/sources/app/auth/methods/types.ts` + - Reference implementation (mTLS): `apps/server/sources/app/auth/methods/modules/mtlsAuthMethodModule.ts` + +3. Register the module by adding it to `staticAuthMethodModules` in: + - `apps/server/sources/app/auth/methods/registry.ts` + +After that: + +- `authRoutes(...)` will register your login routes automatically. +- Boot-time lockout prevention uses the advertised `capabilities.auth.methods[]` actions; ensure your method reports at least one enabled `login`/`provision` action when correctly configured. +- `/v1/features` will advertise methods via `capabilities.auth.methods[]` so clients can render options without guessing. + +## UI considerations + +Adding a server login provider does not automatically add a UI login flow. + +The built-in mTLS flow is implemented as: + +- Web: `POST /v1/auth/mtls` +- Mobile handoff: `/v1/auth/mtls/start` → `/v1/auth/mtls/complete` → `POST /v1/auth/mtls/claim` + +Enterprise forks can implement custom UI flows similarly, while still using the shared login-provider registration and feature advertising from core. diff --git a/apps/docs/content/docs/server/auth-github.mdx b/apps/docs/content/docs/server/auth-github.mdx new file mode 100644 index 000000000..7b5e46a31 --- /dev/null +++ b/apps/docs/content/docs/server/auth-github.mdx @@ -0,0 +1,222 @@ +--- +title: GitHub auth +description: Configure GitHub OAuth (signup + connect) and optional GitHub allowlists for a self-hosted server. +--- + +This guide covers GitHub auth on the Happier server: +- GitHub OAuth (signup + connect) +- Enforcing GitHub as a required login provider +- Optional allowlists (users/orgs) and offboarding behavior + +## Quick start: GitHub signup (recommended for public servers) + +Use this when you want users to **sign up with GitHub** and you want to **disable anonymous signup**. + +Note: Happier accounts are still **device-key** accounts. GitHub acts as an attached identity provider (used for signup, connect, and policy enforcement), not as a replacement for device-key login. + +UI note: +- If both anonymous signup and GitHub signup are enabled, the welcome screen shows **Create account** and **Continue with GitHub**. +- If anonymous signup is disabled, the welcome screen shows **Continue with GitHub** (and no anonymous “Create account” option). + +### 1) Create a GitHub OAuth App + +Happier uses a **GitHub OAuth App** (not a GitHub App) for the OAuth redirect flow. + +1. In GitHub, go to **Settings → Developer settings → OAuth Apps → New OAuth App**. +2. Set **Authorization callback URL** to: + - `https://YOUR_SERVER/v1/oauth/github/callback` +3. Create the app and copy: + - **Client ID** + - **Client secret** + +Notes: +- The Happier server currently talks to `github.com` / `api.github.com` directly, so this setup is for **GitHub.com** (not GitHub Enterprise Server). +- The callback URL must match exactly (scheme/host/path) what you put in `GITHUB_REDIRECT_URL`. +- GitHub generally requires `https://` callback URLs, except for loopback development URLs like `http://localhost:PORT/...`. +- You do **not** need GitHub’s “Device Flow / device authentication flow”. Our server uses the standard browser redirect OAuth flow, so you can leave “Enable Device Flow” **disabled**. + +### 2) Configure the server + +Set these environment variables on `apps/server` (see `apps/server/.env.example`): + +```bash +# GitHub OAuth (OAuth App) +GITHUB_CLIENT_ID=... +GITHUB_CLIENT_SECRET=... +GITHUB_REDIRECT_URL=https://YOUR_SERVER/v1/oauth/github/callback + +# OAuth return redirect back to the client (required unless you are using https://app.happier.dev) +# - Web: set this to the URL you open in the browser for the Happier web app (self-hosted or local dev). +# - Mobile: use HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE + HAPPIER_OAUTH_RETURN_ALLOWED_SCHEMES instead. +HAPPIER_WEBAPP_URL=https://YOUR_WEBAPP + +# Auth policy +AUTH_ANONYMOUS_SIGNUP_ENABLED=false +AUTH_SIGNUP_PROVIDERS=github +AUTH_REQUIRED_LOGIN_PROVIDERS=github +``` + +Alternative (mobile deep link return): +- `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE=myapp://oauth` +- `HAPPIER_OAUTH_RETURN_ALLOWED_SCHEMES=myapp` + +### 3) Validate it works + +1. Restart the server. +2. Check `GET /v1/features` and confirm: + - `capabilities.oauth.providers.github.configured` is `true` +3. Open the Happier app and confirm “Continue with GitHub” is available. +4. If your GitHub login is already taken (or doesn’t match the username rules), Happier will ask you to choose a username during signup. This is expected. + - Important: this is **signup** (creating a new Happier account). If you’re trying to use GitHub to “log in” to an existing Happier account from another device, you must use **device-key restore** (“Login with mobile app”) and then connect GitHub from **Settings → Account**. + +Troubleshooting: +- If you see an error like “provider already linked”, it means this GitHub identity is already connected to an existing Happier account on that server. + - Use **device-key restore** on this device/browser, then continue. + +## Server env (GitHub OAuth) + +Required: +- `GITHUB_CLIENT_ID` +- `GITHUB_CLIENT_SECRET` +- `GITHUB_REDIRECT_URL` (preferred) or legacy `GITHUB_REDIRECT_URI` + - Must be: `https://YOUR_SERVER/v1/oauth/github/callback` + +Required **unless** you are using the hosted client at `https://app.happier.dev`: +- `HAPPIER_WEBAPP_URL` (default `https://app.happier.dev`) + +Optional (typically only needed for mobile deep links / custom schemes): +- `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE` +- `HAPPIER_OAUTH_RETURN_ALLOWED_SCHEMES` +- `OAUTH_STATE_TTL_SECONDS` (default `600`, clamp 60–3600) +- `OAUTH_PENDING_TTL_SECONDS` (default `600`, clamp 60–3600; legacy fallback: `GITHUB_OAUTH_PENDING_TTL_SECONDS`) +- `GITHUB_HTTP_TIMEOUT_SECONDS` (default `10`, clamp 1–120) + +Notes: +- `GITHUB_REDIRECT_URL` must be reachable by GitHub (it’s where GitHub redirects the browser after the user approves OAuth). +- If your server is behind a reverse proxy, `GITHUB_REDIRECT_URL` should be the **public** URL (not an internal container hostname). +- `HAPPIER_WEBAPP_URL` / `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE` are only “optional” if you are using the hosted client at `https://app.happier.dev`. + - If you are self-hosting the web app, or using a local dev web UI, you must set one of these so the server can redirect back to your client after OAuth. + +## OAuth return URL to the client (advanced) + +After the server handles the GitHub callback, it redirects the browser back to the client so the client can finish the flow. + +- Default return base: `HAPPIER_WEBAPP_URL` (`https://app.happier.dev`) + - The server redirects to `${HAPPIER_WEBAPP_URL}/oauth/github?...` unless you override it. +- Override return base (without provider suffix): `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE` + - Example: `https://app.example.com/custom-oauth` → redirects to `https://app.example.com/custom-oauth/github?...` +- Allow additional URL schemes for the return redirect: `HAPPIER_OAUTH_RETURN_ALLOWED_SCHEMES` + - Use this for mobile deep links (e.g. `myapp`) + - `http://` is only allowed for loopback hosts like `localhost`, `127.0.0.1`, and `*.localhost`, and is not allowed for non-loopback hosts. + +Troubleshooting: +- If you get redirected to `https://app.happier.dev` even after setting `HAPPIER_WEBAPP_URL`, the server likely rejected your configured return URL as unsafe. For local web dev, use `http://localhost:PORT` or `http://*.localhost:PORT` (or use `https://`). + +## OAuth scopes + +OAuth scopes requested by the server: +- Default: `read:user` +- Adds `read:org` only when you enable org allowlists **and** set `AUTH_GITHUB_ORG_MEMBERSHIP_SOURCE=oauth_user_token` + +## GitHub allowlists (eligibility) + +Important: +- All values are normalized to lowercase. +- Allowlists affect access only when GitHub is included in `AUTH_REQUIRED_LOGIN_PROVIDERS`. + +### User allowlist +- `AUTH_GITHUB_ALLOWED_USERS` (CSV of GitHub logins) + +If set and GitHub is required for login, the user must match this list or they get `403 not-eligible`. + +### Org allowlist +- `AUTH_GITHUB_ALLOWED_ORGS` (CSV of org slugs) +- `AUTH_GITHUB_ORG_MATCH` (`any`/`all`, default `any`) + +If set and GitHub is required for login, the user must be in the allowed org(s). Membership is re-checked using the offboarding interval. + +### Org membership source (important) + +`AUTH_GITHUB_ORG_MEMBERSHIP_SOURCE` controls how the server verifies org membership: +- `github_app` (recommended) +- `oauth_user_token` (uses the user’s OAuth token and requires `read:org`) + +Default behavior: +- If `AUTH_GITHUB_ALLOWED_ORGS` is empty → `oauth_user_token` (no org checks happen) +- If `AUTH_GITHUB_ALLOWED_ORGS` is set → defaults to `github_app` + +This means: if you set `AUTH_GITHUB_ALLOWED_ORGS` but you do **not** configure GitHub App mode, users will be treated as **ineligible**. + +## GitHub App mode (recommended for org enforcement) + +GitHub App mode avoids relying on user OAuth tokens and keeps org enforcement working even if a user revokes OAuth consent. + +### Server environment variables (GitHub App mode) +- `AUTH_GITHUB_APP_ID` +- `AUTH_GITHUB_APP_PRIVATE_KEY` (PEM) +- `AUTH_GITHUB_APP_INSTALLATION_ID_BY_ORG` (CSV mapping like `acme=123,other=456`) + +### GitHub setup steps (GitHub App) +1. Create a **GitHub App**: **Settings → Developer settings → GitHub Apps → New GitHub App**. +2. Generate a **private key** and copy the PEM into `AUTH_GITHUB_APP_PRIVATE_KEY`. +3. Set the minimal permission needed for membership checks: + - **Organization permissions → Members: Read** +4. Install the app into each org you plan to allow. +5. Capture the **installation id** for each org and set `AUTH_GITHUB_APP_INSTALLATION_ID_BY_ORG`. + +Installation id tips: +- After installing the app, click **Configure** on the installation; the URL usually contains `installations/<id>`. + +## Org enforcement via user OAuth tokens (alternative) + +If you can’t use GitHub App mode, you can verify org membership using the user’s OAuth token: + +```bash +AUTH_GITHUB_ALLOWED_ORGS=acme +AUTH_GITHUB_ORG_MEMBERSHIP_SOURCE=oauth_user_token +GITHUB_STORE_ACCESS_TOKEN=true +``` + +Notes: +- Users will be prompted for `read:org` during OAuth. +- Token storage is required for periodic org re-checks; if you disable token storage, org checks will fail and users can become ineligible. +- If you switch to this mode after users already connected GitHub, they may need to disconnect/reconnect so the server can store a token for membership checks. + +## Token storage (advanced) + +`GITHUB_STORE_ACCESS_TOKEN` controls whether the server persists GitHub access tokens (encrypted-at-rest). + +- Default: `false` for typical setups (Friends / basic GitHub connect) +- Default: effectively `true` when you enable org checks via `oauth_user_token` (token required for offboarding) + +## Example configurations + +### GitHub-only signup + GitHub required for login +```bash +AUTH_ANONYMOUS_SIGNUP_ENABLED=false +AUTH_SIGNUP_PROVIDERS=github +AUTH_REQUIRED_LOGIN_PROVIDERS=github +``` + +### Hybrid (anonymous + GitHub signup) + +This is useful for **private servers** (VPN / Tailscale / LAN), where you want to keep anonymous accounts available but also offer “Continue with GitHub”. + +```bash +AUTH_ANONYMOUS_SIGNUP_ENABLED=true +AUTH_SIGNUP_PROVIDERS=github +AUTH_REQUIRED_LOGIN_PROVIDERS= +``` + +### GitHub-only + restrict to an org (GitHub App mode) +```bash +AUTH_ANONYMOUS_SIGNUP_ENABLED=false +AUTH_SIGNUP_PROVIDERS=github +AUTH_REQUIRED_LOGIN_PROVIDERS=github + +AUTH_GITHUB_ALLOWED_ORGS=acme +AUTH_GITHUB_ORG_MEMBERSHIP_SOURCE=github_app +AUTH_GITHUB_APP_ID=... +AUTH_GITHUB_APP_PRIVATE_KEY=... +AUTH_GITHUB_APP_INSTALLATION_ID_BY_ORG=acme=123 +``` diff --git a/apps/docs/content/docs/server/auth-mtls.mdx b/apps/docs/content/docs/server/auth-mtls.mdx index 64e970c74..7bcccafee 100644 --- a/apps/docs/content/docs/server/auth-mtls.mdx +++ b/apps/docs/content/docs/server/auth-mtls.mdx @@ -14,7 +14,7 @@ Happier can use mTLS to: Important: - mTLS is **authentication**, not end-to-end encryption (E2EE). -- If you disable E2EE (encryption opt-out / plaintext storage), the server can read and index session content (enabling features like server-side search). +- If you disable E2EE (encryption opt-out / plaintext storage), the server can read and index session content (enabling features like server-side search). See: [Encryption & plaintext storage](./encryption). ## When to choose mTLS vs OIDC @@ -88,21 +88,52 @@ Note: switching encryption mode does not retroactively decrypt or re-encrypt his ### Enable mTLS -- `HAPPIER_AUTH_MTLS_ENABLED` (`0`/`1`) -- `HAPPIER_AUTH_MTLS_MODE` (`forwarded` | `direct`) -- `HAPPIER_AUTH_MTLS_AUTO_PROVISION` (`0`/`1`) +- `HAPPIER_FEATURE_AUTH_MTLS__ENABLED` (`0`/`1`) +- `HAPPIER_FEATURE_AUTH_MTLS__MODE` (`forwarded` | `direct`) +- `HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION` (`0`/`1`) ### Identity mapping -- `HAPPIER_AUTH_MTLS_IDENTITY_SOURCE` (`san_email` | `san_upn` | `subject_cn` | `fingerprint`) -- `HAPPIER_AUTH_MTLS_ALLOWED_EMAIL_DOMAINS` (CSV; recommended with `san_email`) -- `HAPPIER_AUTH_MTLS_ALLOWED_ISSUERS` (CSV or regex; recommended) +- `HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE` (`san_email` | `san_upn` | `subject_cn` | `fingerprint`) +- `HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS` (CSV; recommended with `san_email` / `san_upn`) +- `HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS` (recommended; supports CN or exact DN matching) + +Notes: +- CN allowlist entries (most common) can be either `cn=...` or bare CN strings (e.g. `Example Root CA`). Multiple CN entries can be comma-separated. +- Exact DN allowlist entries can be provided as full issuer DNs (e.g. `C=US, O=Example Corp, CN=Example Root CA`). If you want multiple DN entries, separate them with **newlines** or `;` (DN strings include commas). +- Forwarded issuer strings can be either a simple `CN=...` value or a full DN. Happier performs case/whitespace-insensitive normalization. +- Matching behavior: + - if an allowlist entry is CN-only, Happier compares it against the extracted issuer CN. + - if an allowlist entry is a full DN, Happier requires an exact DN match (after normalization). ### Forwarded-header configuration (forwarded mode only) -- `HAPPIER_AUTH_MTLS_TRUST_FORWARDED_HEADERS` (`0`/`1`) -- `HAPPIER_AUTH_MTLS_FORWARDED_FINGERPRINT_HEADER` (default `x-happier-client-cert-sha256`) -- `HAPPIER_AUTH_MTLS_FORWARDED_SUBJECT_HEADER` (optional) +- `HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS` (`0`/`1`) +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER` (default `x-happier-client-cert-email`) +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_UPN_HEADER` (default `x-happier-client-cert-upn`) +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_SUBJECT_HEADER` (default `x-happier-client-cert-subject`) +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_FINGERPRINT_HEADER` (default `x-happier-client-cert-sha256`) +- `HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER` (default `x-happier-client-cert-issuer`) + +### Native app handoff (mobile) + +- `HAPPIER_FEATURE_AUTH_MTLS__RETURN_TO_ALLOW_PREFIXES` (CSV; defaults to `happier://` and `HAPPIER_WEBAPP_URL`) +- `HAPPIER_FEATURE_AUTH_MTLS__CLAIM_TTL_SECONDS` (default `60`) + +Notes: + +- For `http://` / `https://` entries, Happier validates `returnTo` by **origin** (scheme + host + port), with an optional path-prefix constraint if you include a path in the allowlist entry. This prevents “prefix” open-redirect bypasses like `https://app.example.com.evil.com/...`. +- For deep links, use a scheme prefix like `happier://` (or a more specific prefix like `happier:///mtls`). + +### Auto-redirect (skip the connection screen) + +If mTLS is the only supported login flow for your deployment, you can enable auto-redirect so users don’t have to tap “Sign in with certificate”. + +- `HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED=1` +- `HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_PROVIDER_ID=mtls` +- Disable anonymous signup: `AUTH_ANONYMOUS_SIGNUP_ENABLED=0` + +On mobile, this will open the system browser to start the certificate-authenticated flow. For the full canonical list, see [Deployment → Environment variables](../deployment/env). @@ -125,6 +156,8 @@ For the full canonical list, see [Deployment → Environment variables](../deplo - CA chain configured for verification. 3. Enable direct mode in Happier and confirm the API rejects requests without a valid client cert. +Note: Direct mode is not implemented yet in the open-source server; use forwarded mode for now. + ## Troubleshooting ### “mTLS login isn’t offered in the UI” @@ -134,12 +167,21 @@ For the full canonical list, see [Deployment → Environment variables](../deplo ### “Auto-provisioning doesn’t create accounts” -- Confirm `HAPPIER_AUTH_MTLS_AUTO_PROVISION=1`. +- Confirm `HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION=1`. - Confirm your identity mapping is producing a stable id (use `san_email`/`san_upn` when possible). -- Confirm issuer/domain allowlists are not rejecting the cert. +- Confirm domain allowlists are not rejecting the cert. + +Also confirm that plaintext storage is allowed if you want keyless auto-provisioning: + +- `HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY=optional|plaintext_only` +- `HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE=plain` ### “Users get multiple accounts across devices” - You are likely using `fingerprint` identity mapping. - Switch to `san_email`/`san_upn`, and enforce issuer + domain allowlists. +### Identity normalization notes + +- `san_email` and `san_upn` are normalized to lowercase. +- Issuer comparisons are case-insensitive and whitespace-insensitive (but you must still configure the allowlist with comma-separated issuer strings). diff --git a/apps/docs/content/docs/server/auth-oidc.mdx b/apps/docs/content/docs/server/auth-oidc.mdx new file mode 100644 index 000000000..bdacb1311 --- /dev/null +++ b/apps/docs/content/docs/server/auth-oidc.mdx @@ -0,0 +1,126 @@ +--- +title: OIDC auth +description: Configure generic OIDC providers (Okta/Auth0/Azure AD/Keycloak, etc.) for a self-hosted server. +--- + +Happier can be configured with **one or more** OIDC provider instances. This is intended for enterprise/self-hosting deployments where GitHub isn’t the desired identity provider. + +## What standard is supported? + +Happier’s “custom provider” support is **OpenID Connect (OIDC) 1.0** using **OAuth 2.0 Authorization Code Flow** with **PKCE**. + +- OIDC discovery is required (`/.well-known/openid-configuration` under your `issuer`) +- The provider must support code exchange at the token endpoint +- `id_token` claims are used as the primary identity profile; optional `/userinfo` can be enabled +- Optional refresh tokens can be stored (encrypted) for offboarding re-checks + +If you need SAML, the recommended approach is to use your IdP’s “OIDC app” or a SAML→OIDC bridge (Okta/Auth0/Azure AD/Keycloak, etc.), and configure Happier using the OIDC options below. + +## Config sources + +You can configure providers via either: +- `AUTH_PROVIDERS_CONFIG_PATH` (recommended): path to a JSON file on the server +- `AUTH_PROVIDERS_CONFIG_JSON`: a JSON string (useful for simple deployments) + +## JSON schema (v1) + +The config must be a JSON array. Each entry defines one provider instance: + +```json +[ + { + "id": "okta", + "type": "oidc", + "displayName": "Acme Okta", + "issuer": "https://acme.okta.com/oauth2/default", + "clientId": "…", + "clientSecret": "…", + "redirectUrl": "https://YOUR_SERVER/v1/oauth/okta/callback", + + "scopes": "openid profile email", + "httpTimeoutSeconds": 30, + "claims": { + "login": "preferred_username", + "email": "email", + "groups": "groups" + }, + "allow": { + "usersAllowlist": ["alice", "bob"], + "emailDomains": ["example.com"], + "groupsAny": ["engineering", "platform"], + "groupsAll": ["employees"] + }, + "fetchUserInfo": false, + "storeRefreshToken": false, + "ui": { + "buttonColor": "#111111", + "iconHint": "okta" + } + } +] +``` + +Notes: +- `id` becomes the provider id used in policy (`AUTH_SIGNUP_PROVIDERS=okta`) and in the callback URL (`/v1/oauth/:provider/callback`). +- `issuer` must point at the provider’s OIDC issuer URL (discovery uses `/.well-known/openid-configuration`). +- Make sure your OIDC app is configured to allow the exact `redirectUrl`. +- `scopes` defaults to `openid profile email` and **must include `openid`**. +- `httpTimeoutSeconds` (default `30`, clamp 1–120): timeout (seconds) for OIDC discovery and subsequent HTTP requests made by the OIDC client configuration. +- `claims` lets you map where to read the provider login/email/groups from (defaults shown above). +- `allow` restricts eligibility (all comparisons are lowercased): + - `usersAllowlist`: matches against the provider login claim. + - `emailDomains`: allows `user@domain` only if `domain` matches. + - `groupsAny`: user must be in *at least one* group. + - `groupsAll`: user must be in *all* groups. +- `fetchUserInfo` (default `false`): if enabled, the server fetches `/userinfo` and merges it into the identity profile. +- `storeRefreshToken` (default `false`): if enabled, the server stores an **encrypted refresh token** and uses refresh flows for periodic eligibility re-checks (offboarding) without requiring users to reconnect. +- `ui` provides optional UI hints only (no security impact). + +## Group overage / external claims + +Some providers (notably Microsoft Entra ID / Azure AD) may omit groups from tokens and instead return an “overage” pointer (e.g. `_claim_names/_claim_sources`). If you configure `groupsAny`/`groupsAll`, Happier will **fail closed** when it detects group overage (treating the user as ineligible) unless it can read groups from the configured claim source (for example by enabling `fetchUserInfo` and mapping `claims.groups` appropriately). + +## Enabling OIDC signup / login enforcement + +Example: **Okta-only signup** + **Okta required for ongoing access**: + +```bash +AUTH_ANONYMOUS_SIGNUP_ENABLED=false +AUTH_SIGNUP_PROVIDERS=okta +AUTH_REQUIRED_LOGIN_PROVIDERS=okta + +AUTH_PROVIDERS_CONFIG_PATH=/etc/happier/auth-providers.json +``` + +## Step-by-step: add a new OIDC provider + +1. Pick a provider id (lowercase), e.g. `okta` / `auth0` / `entra`. +2. In your IdP admin console, create an **OIDC application**: + - Grant type: **Authorization Code** + - Enable **PKCE** + - Add redirect URI: `https://YOUR_SERVER/v1/oauth/<providerId>/callback` +3. Put the provider config in `AUTH_PROVIDERS_CONFIG_PATH` (preferred) or `AUTH_PROVIDERS_CONFIG_JSON`: + - Set `issuer`, `clientId`, `clientSecret`, and `redirectUrl` exactly (must match step 2). +4. Enable it in policy: + - Allow signup via this provider: `AUTH_SIGNUP_PROVIDERS=<providerId>` + - Make it mandatory for ongoing access: `AUTH_REQUIRED_LOGIN_PROVIDERS=<providerId>` + - Disable anonymous signup if desired: `AUTH_ANONYMOUS_SIGNUP_ENABLED=false` +5. Make sure the client app can return from OAuth: + - Web (required unless you use `https://app.happier.dev`): set `HAPPIER_WEBAPP_URL` (or `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE`) + - Mobile deep link: set `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE` and allow the scheme via `HAPPIER_OAUTH_RETURN_ALLOWED_SCHEMES` (e.g. `myapp`) +6. Validate: + - Restart the server (so it picks up env / config changes). + - Open the Happier app and confirm the new sign-in option appears on the welcome/login screen. + - If it doesn’t, double-check the redirect URL, issuer URL, and that `AUTH_SIGNUP_PROVIDERS` / `AUTH_REQUIRED_LOGIN_PROVIDERS` reference the same provider id. (For troubleshooting, you can also inspect `GET /v1/features`.) + +Note on “optional” web app URL env vars: +- `HAPPIER_WEBAPP_URL` / `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE` are only “optional” if you are using the hosted client at `https://app.happier.dev`. + - If you are self-hosting the web app, or using a local dev web UI, you must set one of these so the server can redirect back to your client after OAuth. + +Troubleshooting: +- If you get redirected to `https://app.happier.dev` even after setting `HAPPIER_WEBAPP_URL`, the server likely rejected your configured return URL as unsafe. For local web dev, use `http://localhost:PORT` or `http://*.localhost:PORT` (or use `https://`). + +## “Custom providers” beyond OIDC + +At the moment, “custom providers” are supported via **OIDC configuration** (no plugin system for arbitrary protocols). +If you need a non-OIDC provider, it must be implemented as a new provider module in the server and UI (similar to the built-in GitHub provider). diff --git a/apps/docs/content/docs/server/auth.mdx b/apps/docs/content/docs/server/auth.mdx index f65f56aa3..e3302f0fa 100644 --- a/apps/docs/content/docs/server/auth.mdx +++ b/apps/docs/content/docs/server/auth.mdx @@ -1,275 +1,162 @@ --- title: Auth -description: Configure signup/login policies and GitHub enforcement for a self-hosted server. +description: Overview of server authentication and policy configuration. --- -Happier supports a **device-key** account model (each account is tied to a device secret), and can optionally enforce **provider-based authentication** and **eligibility policies** (enterprise/self-hosting). +This guide is for **server operators**. It explains the core auth model and the policy env vars that control signup/login enforcement. -## mTLS (client certificates) +For provider-specific setup guides, see: +- [GitHub auth](./auth-github) +- [OIDC auth](./auth-oidc) +- [mTLS auth](./auth-mtls) +- [Custom auth providers](./auth-custom-providers) -mTLS is an enterprise authentication option where the user’s device presents a company-issued certificate during the TLS handshake. +## Choosing an auth setup (private vs public servers) -- It can be used as a login method (no OAuth redirect flow). -- It does **not** automatically imply end-to-end encryption (E2EE). -- For most enterprise deployments that want server-side search and do not want user-managed keys, combine mTLS with **encryption opt-out** (plaintext storage mode). +What you should configure depends mostly on **who can reach your server**. -See the dedicated guide: [mTLS Auth](./auth-mtls). +### Private server (Tailscale/VPN/LAN only) -## Core concepts +If your server is only reachable from a private network (for example via Tailscale), you often don’t need provider-based auth: -### Anonymous vs provider signup -- **Anonymous signup** (default): users can create an account without an external provider. -- **Provider signup**: users create an account via an external auth provider (GitHub and/or your configured OIDC providers). +- Recommended: keep **anonymous signup enabled** (default) and rely on network access control. +- Make sure your server is **not** reachable from the public internet (firewall / security group / reverse proxy). -### Required providers for ongoing access -You can enforce that a user must have one or more linked providers to keep using the server: -- If a required provider is missing → requests are rejected (`403 provider-required`). -- If allowlists are configured and the user becomes ineligible → requests are rejected (`403 not-eligible`). +Example config: -Eligibility is enforced for: -- Authenticated HTTP routes -- Socket.IO handshake (`/v1/updates`) +```bash +AUTH_ANONYMOUS_SIGNUP_ENABLED=true +AUTH_SIGNUP_PROVIDERS= +AUTH_REQUIRED_LOGIN_PROVIDERS= +``` + +Optional (private servers): you can also enable GitHub/OIDC **in addition** to anonymous signup, to offer multiple signup options: + +```bash +AUTH_ANONYMOUS_SIGNUP_ENABLED=true +AUTH_SIGNUP_PROVIDERS=github +AUTH_REQUIRED_LOGIN_PROVIDERS= +``` + +### Public server (reachable from the internet) + +If your server is publicly reachable, leaving anonymous signup enabled makes it easy for anyone to create an account. In that setup, it’s usually better to require an identity provider: + +- Recommended: **disable anonymous signup** +- Recommended: require a provider for ongoing access (`AUTH_REQUIRED_LOGIN_PROVIDERS`) +- Optional: restrict eligibility with allowlists (for example “must be in GitHub org X”) + +Example (GitHub): + +```bash +AUTH_ANONYMOUS_SIGNUP_ENABLED=false +AUTH_SIGNUP_PROVIDERS=github +AUTH_REQUIRED_LOGIN_PROVIDERS=github +``` + +Then follow the provider setup guide: +- GitHub: [GitHub auth](./auth-github) +- OIDC: [OIDC auth](./auth-oidc) + +## Standard authentication (device-key accounts) + +Happier uses a **device-key** account model (each account is tied to a device secret). + +- Default behavior: users can create accounts via device-key signup (`POST /v1/auth`). +- You can optionally require external providers (GitHub and/or OIDC) for signup and/or ongoing access. +- External providers are **not** “password login”: if a user is trying to access an *existing* Happier account from a new device/browser, they still need to **restore** the account (for example “Login with mobile app”), then connect providers from settings. + +## Auth methods (discovery) + +Clients should treat `GET /v1/features` as the source of truth for “what auth flows are available”. + +Happier advertises auth entrypoints via: + +- `capabilities.auth.methods[]` (preferred; method + action + mode) +- plus legacy surfaces like `capabilities.auth.signup.methods[]` for older clients + +Each method can advertise multiple actions: + +- `login` (sign in to an existing account) +- `provision` (create an account) +- `connect` (link an identity provider to an already-authenticated account) + +And each action declares its account key mode: + +- `keyed`: device-key account (default Happier model) +- `keyless`: no device keys (enterprise mode; typically paired with plaintext storage) + +## Keyless authentication (enterprise) -### Offboarding behavior (per-request caching) -When allowlists are configured and offboarding is enabled, the server re-checks eligibility periodically and caches results on the identity record. +Some deployments want accounts to exist **without device keys** (for example: company-managed identity, server-side indexing/search, and simpler multi-device behavior). -- Mode: `per-request-cache` only -- Interval: `AUTH_OFFBOARDING_INTERVAL_SECONDS` (default `86400`) +Happier currently supports keyless login via: -## Auth policy environment variables +- [mTLS auth](./auth-mtls) +- keyless OAuth login (GitHub/OIDC) when enabled by feature env vars + +Keyless account auto-provisioning is intentionally guarded: + +- enable keyless accounts: `HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED=1` +- ensure new accounts default to plaintext: `HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY=optional|plaintext_only` and `HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE=plain` +- for OAuth keyless login: `HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED=1` + allowlist providers via `HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS` + +## OAuth return URL (required for GitHub/OIDC self-hosting) + +If you enable any external OAuth providers (GitHub and/or OIDC), the server must know where to redirect the browser **back to the client app** after OAuth completes. + +- If you use the hosted web app, you don’t need to set anything (default is `https://app.happier.dev`). +- If you self-host the web app (or you’re using a local dev UI), you **must** configure one of: + - `HAPPIER_WEBAPP_URL` (most common for web) + - `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE` (often used for mobile deep links or custom paths) + +Otherwise, users will be redirected to the wrong place (or the redirect may be rejected as unsafe). + +## Auth policy (env vars) + +Use these env vars to control who can create accounts and whether provider identity is enforced for ongoing access. ### Signup / login policy + - `AUTH_ANONYMOUS_SIGNUP_ENABLED` (default `true`) - - If `false`, anonymous account creation is disabled. + - If `false`, anonymous account creation is disabled (`POST /v1/auth` will return `403 signup-disabled` for new users). - `AUTH_SIGNUP_PROVIDERS` (CSV, default empty) - - Enables provider-based signup (e.g. `github`). + - Enables provider-based signup (e.g. `github`, `okta`). - `AUTH_REQUIRED_LOGIN_PROVIDERS` (CSV, default empty) - - Enforces providers for ongoing access (e.g. `github`). + - Enforces providers for ongoing access (e.g. `github`, `okta`). + +### What enforcement applies to + +When `AUTH_REQUIRED_LOGIN_PROVIDERS` is set, eligibility is enforced for: +- Authenticated HTTP routes (via the `authenticate` pre-handler) +- Socket.IO handshake (`/v1/updates`) + +If a required provider is missing: +- Requests are rejected with `403 provider-required`. ### Offboarding / eligibility checks + +If you use allowlists (GitHub org allowlists or OIDC allow rules), the server can re-check eligibility periodically and cache results on the identity record. + - `AUTH_OFFBOARDING_ENABLED` - - Defaults to `true` when any `AUTH_*_ALLOWED_*` allowlist is configured; otherwise `false`. + - Defaults to `true` when any *membership-based* allowlists are configured (GitHub org allowlist and/or OIDC allow rules); otherwise `false`. - `AUTH_OFFBOARDING_INTERVAL_SECONDS` (default `86400`, clamp 60–86400) - `AUTH_OFFBOARDING_STRICT` (default `false`) - When `true`, the server **fails closed** if it cannot re-check eligibility due to upstream downtime (enterprise lock-down mode). -## GitHub OAuth (link identity + provider signup) - -GitHub OAuth is used to: -- Link a GitHub identity to an existing account (connect flow) -- Create an account via GitHub (provider signup flow), when enabled by policy - -### Server environment variables (OAuth) -- `GITHUB_CLIENT_ID` -- `GITHUB_CLIENT_SECRET` -- `GITHUB_REDIRECT_URL` (preferred) or legacy `GITHUB_REDIRECT_URI` - - Set to: `https://YOUR_SERVER/v1/oauth/github/callback` -- `HAPPIER_WEBAPP_URL` (default `https://app.happier.dev`) - - Base URL for the client app. -- `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE` (optional) - - Override the base OAuth return URL to the client (without provider suffix). - - Example: `https://app.example.com/custom-oauth` → server redirects to `https://app.example.com/custom-oauth/github?...` -- `HAPPIER_OAUTH_RETURN_ALLOWED_SCHEMES` (optional) - - Allow additional OAuth return URL schemes (CSV/whitespace). Default allows `https://` and loopback `http://localhost`. - - Useful for mobile deep links (e.g. `myapp`). -- `OAUTH_STATE_TTL_SECONDS` (default `600`, clamp 60–3600) - - How long the OAuth `state` token is valid for (connect + provider signup). -- `GITHUB_HTTP_TIMEOUT_SECONDS` (default `10`, clamp 1–120) - - Timeout for GitHub OAuth and membership HTTP calls. - -OAuth scopes: -- Default: `read:user` -- If you use **org membership checks via user tokens** (`AUTH_GITHUB_ORG_MEMBERSHIP_SOURCE=oauth_user_token`), the server requests: `read:user read:org` - -### GitHub allowlists (enterprise/self-hosting) - -#### User allowlist (logins) -- `AUTH_GITHUB_ALLOWED_USERS` (CSV of lowercase GitHub logins) - -If set, users must match this allowlist (otherwise `403 not-eligible`). - -#### Org allowlist (hard offboarding) -- `AUTH_GITHUB_ALLOWED_ORGS` (CSV of lowercase org slugs) -- `AUTH_GITHUB_ORG_MATCH` (`any`/`all`, default `any`) - -If set, users must be in the allowed org(s), re-checked on the offboarding interval. - -#### Org membership source -- `AUTH_GITHUB_ORG_MEMBERSHIP_SOURCE` - - `github_app` (recommended when org allowlist is set) - - `oauth_user_token` (requires `read:org` and relies on user tokens) - -### GitHub App mode (recommended for org membership enforcement) - -GitHub App mode avoids relying on user OAuth tokens, and keeps org offboarding enforceable even if a user revokes OAuth. - -#### Server environment variables (GitHub App mode) -- `AUTH_GITHUB_APP_ID` -- `AUTH_GITHUB_APP_PRIVATE_KEY` (PEM) -- `AUTH_GITHUB_APP_INSTALLATION_ID_BY_ORG` (CSV mapping like `acme=123,other=456`) - -#### GitHub setup steps (overview) -1. Create a **GitHub App** (GitHub → Settings → Developer settings → GitHub Apps). -2. Generate a private key and set `AUTH_GITHUB_APP_PRIVATE_KEY`. -3. Grant the minimal permission needed for membership checks: - - Organization permissions → **Members: Read** -4. Install the app into each allowed org. -5. Capture the installation ID for each org and set `AUTH_GITHUB_APP_INSTALLATION_ID_BY_ORG`. - -## Generic OIDC providers (Okta/Auth0/Azure AD/Keycloak, etc.) - -Happier can be configured with **one or more** OIDC provider instances. This is intended for enterprise/self-hosting deployments where GitHub isn’t the desired identity provider. - -### What standard is supported? -Happier’s “custom provider” support is **OpenID Connect (OIDC) 1.0** using **OAuth 2.0 Authorization Code Flow** with **PKCE**. - -- OIDC discovery is required (`/.well-known/openid-configuration` under your `issuer`) -- The provider must support code exchange at the token endpoint -- `id_token` claims are used as the primary identity profile; optional `/userinfo` can be enabled -- Optional refresh tokens can be stored (encrypted) for offboarding re-checks - -If you need SAML, the recommended approach is to use your IdP’s “OIDC app” or a SAML→OIDC bridge (Okta/Auth0/Azure AD/Keycloak, etc.), and configure Happier using the OIDC options below. - -### Config sources -You can configure providers via either: -- `AUTH_PROVIDERS_CONFIG_PATH` (recommended): path to a JSON file on the server -- `AUTH_PROVIDERS_CONFIG_JSON`: a JSON string (useful for simple deployments) - -### JSON schema (v1) -The config must be a JSON array. Each entry defines one provider instance: - -```json -[ - { - "id": "okta", - "type": "oidc", - "displayName": "Acme Okta", - "issuer": "https://acme.okta.com/oauth2/default", - "clientId": "…", - "clientSecret": "…", - "redirectUrl": "https://YOUR_SERVER/v1/oauth/okta/callback", - - "scopes": "openid profile email", - "httpTimeoutSeconds": 30, - "claims": { - "login": "preferred_username", - "email": "email", - "groups": "groups" - }, - "allow": { - "usersAllowlist": ["alice", "bob"], - "emailDomains": ["example.com"], - "groupsAny": ["engineering", "platform"], - "groupsAll": ["employees"] - }, - "fetchUserInfo": false, - "storeRefreshToken": false, - "ui": { - "buttonColor": "#111111", - "iconHint": "okta" - } - } -] -``` - -Notes: -- `id` becomes the provider id used in policy (`AUTH_SIGNUP_PROVIDERS=okta`) and in the callback URL (`/v1/oauth/:provider/callback`). -- `issuer` must point at the provider’s OIDC issuer URL (discovery uses `/.well-known/openid-configuration`). -- Make sure your OIDC app is configured to allow the exact `redirectUrl`. -- `scopes` defaults to `openid profile email` and **must include `openid`**. -- `httpTimeoutSeconds` (default `30`, clamp 1–120): timeout (seconds) for OIDC discovery and subsequent HTTP requests made by the OIDC client configuration. -- `claims` lets you map where to read the provider login/email/groups from (defaults shown above). -- `allow` restricts eligibility (all comparisons are lowercased): - - `usersAllowlist`: matches against the provider login claim. - - `emailDomains`: allows `user@domain` only if `domain` matches. - - `groupsAny`: user must be in *at least one* group. - - `groupsAll`: user must be in *all* groups. -- `fetchUserInfo` (default `false`): if enabled, the server fetches `/userinfo` and merges it into the identity profile. -- `storeRefreshToken` (default `false`): if enabled, the server stores an **encrypted refresh token** and uses refresh flows for periodic eligibility re-checks (offboarding) without requiring users to reconnect. -- `ui` provides optional UI hints only (no security impact). - -### Group overage / external claims -Some providers (notably Microsoft Entra ID / Azure AD) may omit groups from tokens and instead return an “overage” pointer (e.g. `_claim_names/_claim_sources`). If you configure `groupsAny`/`groupsAll`, Happier will **fail closed** when it detects group overage (treating the user as ineligible) unless it can read groups from the configured claim source (for example by enabling `fetchUserInfo` and mapping `claims.groups` appropriately). - -### Enabling OIDC signup / login enforcement -Example: **Okta-only signup** + **Okta required for ongoing access**: +## Common configurations (examples) -```bash -AUTH_ANONYMOUS_SIGNUP_ENABLED=false -AUTH_SIGNUP_PROVIDERS=okta -AUTH_REQUIRED_LOGIN_PROVIDERS=okta - -AUTH_PROVIDERS_CONFIG_PATH=/etc/happier/auth-providers.json -``` - -### Step-by-step: add a new OIDC provider -1. Pick a provider id (lowercase), e.g. `okta` / `auth0` / `entra`. -2. In your IdP admin console, create an **OIDC application**: - - Grant type: **Authorization Code** - - Enable **PKCE** - - Add redirect URI: `https://YOUR_SERVER/v1/oauth/<providerId>/callback` -3. Put the provider config in `AUTH_PROVIDERS_CONFIG_PATH` (preferred) or `AUTH_PROVIDERS_CONFIG_JSON`: - - Set `issuer`, `clientId`, `clientSecret`, and `redirectUrl` exactly (must match step 2). -4. Enable it in policy: - - Allow signup via this provider: `AUTH_SIGNUP_PROVIDERS=<providerId>` - - Make it mandatory for ongoing access: `AUTH_REQUIRED_LOGIN_PROVIDERS=<providerId>` - - Disable anonymous signup if desired: `AUTH_ANONYMOUS_SIGNUP_ENABLED=false` -5. Make sure the client app can return from OAuth: - - Web: set `HAPPIER_WEBAPP_URL` (or `HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE`) - - Mobile deep link: allow the scheme via `HAPPIER_OAUTH_RETURN_ALLOWED_SCHEMES` (e.g. `myapp`) -6. Validate: - - Restart the server (so it picks up env / config changes). - - Open the Happier app and confirm the new sign-in option appears on the welcome/login screen. - - If it doesn’t, double-check the redirect URL, issuer URL, and that `AUTH_SIGNUP_PROVIDERS` / `AUTH_REQUIRED_LOGIN_PROVIDERS` reference the same provider id. (For troubleshooting, you can also inspect `GET /v1/features`.) - -### “Custom providers” beyond OIDC -At the moment, “custom providers” are supported via **OIDC configuration** (no plugin system for arbitrary protocols). -If you need a non-OIDC provider, it must be implemented as a new provider module in the server and UI (similar to the built-in GitHub provider). - -## Recommended configurations - -### 1) Default (anonymous + optional GitHub connect) +### Default (anonymous signup) ```bash AUTH_ANONYMOUS_SIGNUP_ENABLED=true AUTH_SIGNUP_PROVIDERS= AUTH_REQUIRED_LOGIN_PROVIDERS= ``` -### 2) GitHub-only signup + GitHub required for login -```bash -AUTH_ANONYMOUS_SIGNUP_ENABLED=false -AUTH_SIGNUP_PROVIDERS=github -AUTH_REQUIRED_LOGIN_PROVIDERS=github -``` +### GitHub-only signup + GitHub required for login -### 3) GitHub-only + restrict to an org (GitHub App mode) -```bash -AUTH_ANONYMOUS_SIGNUP_ENABLED=false -AUTH_SIGNUP_PROVIDERS=github -AUTH_REQUIRED_LOGIN_PROVIDERS=github +See [GitHub auth](./auth-github) for full setup steps. -AUTH_GITHUB_ALLOWED_ORGS=acme -AUTH_GITHUB_ORG_MEMBERSHIP_SOURCE=github_app -AUTH_GITHUB_APP_ID=... -AUTH_GITHUB_APP_PRIVATE_KEY=... -AUTH_GITHUB_APP_INSTALLATION_ID_BY_ORG=acme=123 -``` +### OIDC-only signup (example: Okta) + OIDC required for login -### 4) Okta-only signup (OIDC) -```bash -AUTH_ANONYMOUS_SIGNUP_ENABLED=false -AUTH_SIGNUP_PROVIDERS=okta -AUTH_REQUIRED_LOGIN_PROVIDERS=okta - -AUTH_PROVIDERS_CONFIG_JSON='[ - { - "id": "okta", - "type": "oidc", - "displayName": "Acme Okta", - "issuer": "https://acme.okta.com/oauth2/default", - "clientId": "…", - "clientSecret": "…", - "redirectUrl": "https://YOUR_SERVER/v1/oauth/okta/callback" - } -]' -``` +See [OIDC auth](./auth-oidc) for provider configuration. diff --git a/apps/docs/content/docs/server/encryption.mdx b/apps/docs/content/docs/server/encryption.mdx new file mode 100644 index 000000000..52e6bd0c4 --- /dev/null +++ b/apps/docs/content/docs/server/encryption.mdx @@ -0,0 +1,108 @@ +--- +title: Encryption & plaintext storage +description: Configure whether the server stores session content end-to-end encrypted or as plaintext. +--- + +Happier is designed around **end-to-end encryption (E2EE)**: clients encrypt session content locally and the server stores **ciphertext**. + +For some self-hosted or enterprise deployments, you may want to **opt out of E2EE for storage** so the server can store session content as **plaintext** (for example to enable future server-side indexing/search). + +This page explains the **server operator** configuration and what it means for **end users**. + +## What changes when plaintext storage is enabled? + +- **E2EE storage (default)**: the server cannot read session transcripts (it stores encrypted blobs). +- **Plaintext storage**: the server *can* read and process new session content stored in plaintext. + +Important: plaintext storage is a **storage mode**, not an authentication mode. +- Phase 1 plaintext storage still uses the normal Happier login model (device-key accounts). +- Users can choose plaintext for new sessions only when the server policy allows it. + +## Storage policy (server-wide) + +Configure one of three storage policies: + +- `required_e2ee` (default): encrypted-only; plaintext writes are rejected. +- `optional`: encrypted or plaintext is allowed depending on account/session mode. +- `plaintext_only`: plaintext-only; encrypted writes are rejected. + +Env var: + +```bash +HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY=required_e2ee|optional|plaintext_only +``` + +### Recommended configurations + +Default E2EE server: + +```bash +HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY=required_e2ee +``` + +Allow plaintext storage (opt-in per account): + +```bash +HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY=optional +HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT=1 +``` + +Force plaintext-only (for “server-side features first” deployments): + +```bash +HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY=plaintext_only +``` + +## Account encryption mode (end-user toggle) + +When the server policy is `optional` and account opt-out is enabled, the UI can expose an account-level toggle: + +- `e2ee`: new sessions default to encrypted storage +- `plain`: new sessions default to plaintext storage + +Env vars: + +```bash +HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT=0|1 +HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE=e2ee|plain +``` + +Notes: +- Switching account mode affects **new sessions** created after the change. +- Existing sessions are **not** migrated (no retroactive decrypt/re-encrypt). + +## Per-session encryption mode + +Each session has a stable `encryptionMode` (`e2ee` or `plain`) chosen at creation time. + +This prevents “mixed-mode” content inside one session and makes it predictable for clients (and for future server-side features). + +## Sharing behavior + +- **Encrypted (E2EE) sessions**: sharing requires distributing a session data key to recipients (still E2EE). +- **Plaintext sessions**: sharing is purely server-side authorization (no encrypted data key is needed). + +Public share links also work for plaintext sessions (the server returns plaintext envelopes). + +## Client compatibility + +- Older clients that only support encrypted writes continue to work when: + - policy is `required_e2ee`, or + - policy is `optional` and they create encrypted sessions. +- Under `plaintext_only`, older clients will fail because encrypted writes are rejected. + +## Where clients learn the policy + +Clients use `GET /v1/features`: +- gates: `features.encryption.plaintextStorage.enabled`, `features.encryption.accountOptOut.enabled` +- details: `capabilities.encryption.storagePolicy`, `capabilities.encryption.defaultAccountMode` + +## Security checklist (plaintext mode) + +If you store plaintext session content on the server, treat it as sensitive application data: + +- Enforce HTTPS/TLS end-to-end (including between proxies and the server). +- Encrypt disks and backups at rest. +- Restrict database access and enable audit logging where possible. +- Define retention and deletion policies appropriate for your org. + diff --git a/apps/docs/content/docs/server/index.mdx b/apps/docs/content/docs/server/index.mdx index e3d9d6d3f..289a9ed82 100644 --- a/apps/docs/content/docs/server/index.mdx +++ b/apps/docs/content/docs/server/index.mdx @@ -5,8 +5,11 @@ description: Backend configuration, deployment notes, and authentication policie This section documents the Happier backend (`apps/server`) behavior and configuration. -- Authentication policies and provider setup: see [Auth](./auth) -- mTLS client certificate authentication (enterprise): see [mTLS Auth](./auth-mtls) +- Authentication overview + policy env vars: see [Auth](./auth) +- Encryption storage policy (E2EE vs plaintext): see [Encryption & plaintext storage](./encryption) +- GitHub OAuth + GitHub allowlists: see [GitHub auth](./auth-github) +- Generic OIDC providers (Okta/Auth0/Azure AD/Keycloak, etc.): see [OIDC auth](./auth-oidc) +- mTLS client certificate authentication (enterprise): see [mTLS auth](./auth-mtls) - Deployment: see [Deployment](../deployment) - Full server env reference: see [Deployment → Environment variables](../deployment/env) - Canonical env template file: `apps/server/.env.example` diff --git a/apps/docs/content/docs/server/meta.json b/apps/docs/content/docs/server/meta.json index 8e4b573b8..52e39ea51 100644 --- a/apps/docs/content/docs/server/meta.json +++ b/apps/docs/content/docs/server/meta.json @@ -3,6 +3,10 @@ "pages": [ "index", "auth", + "encryption", + "auth-custom-providers", + "auth-github", + "auth-oidc", "auth-mtls" ] } diff --git a/apps/server/.env.example b/apps/server/.env.example index 77f550dfa..dbdd45058 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -25,6 +25,61 @@ METRICS_PORT=9090 # Optional stable process id (helpful in clustered deployments) # HAPPIER_INSTANCE_ID=api-1 +############################ +# Networking / reverse proxy (optional) +############################ +# Controls whether the server trusts X-Forwarded-* headers for client IP / protocol. +# Recommended behind a single reverse proxy (nginx/traefik/caddy): set to 1. +# WARNING: Do not enable unless the server is not directly reachable by untrusted clients. +# HAPPIER_SERVER_TRUST_PROXY=1 + +############################ +# API rate limiting (optional but recommended) +############################ +# Master enable/disable for all API rate limits. +HAPPIER_API_RATE_LIMITS_ENABLED=true +# Optional global limit applied to all routes (0 disables). +HAPPIER_API_RATE_LIMITS_GLOBAL_MAX=0 +HAPPIER_API_RATE_LIMITS_GLOBAL_WINDOW=1 minute +# Optional: rate limit keying strategy +# - auth-or-ip: prefer Authorization hash, fall back to IP (default) +# - auth-only: Authorization hash only (unauth requests share one bucket) +# - ip-only: IP only +HAPPIER_API_RATE_LIMIT_KEY_MODE=auth-or-ip +# Optional: IP source when IP keying is used +# - fastify: request.ip (recommended; set HAPPIER_SERVER_TRUST_PROXY behind a reverse proxy) +# - x-forwarded-for: X-Forwarded-For (only safe when server is not directly reachable) +# - x-real-ip: X-Real-Ip (only safe when server is not directly reachable) +HAPPIER_API_RATE_LIMIT_CLIENT_IP_SOURCE=fastify +# +# Hot endpoints (set *_MAX=0 to disable a specific limit). +HAPPIER_SESSION_MESSAGES_RATE_LIMIT_MAX=600 +HAPPIER_SESSION_MESSAGES_RATE_LIMIT_WINDOW=1 minute +HAPPIER_SESSION_MESSAGES_BY_LOCAL_ID_RATE_LIMIT_MAX=600 +HAPPIER_SESSION_MESSAGES_BY_LOCAL_ID_RATE_LIMIT_WINDOW=1 minute +HAPPIER_SESSIONS_LIST_RATE_LIMIT_MAX=300 +HAPPIER_SESSIONS_LIST_RATE_LIMIT_WINDOW=1 minute +HAPPIER_CHANGES_RATE_LIMIT_MAX=600 +HAPPIER_CHANGES_RATE_LIMIT_WINDOW=1 minute +HAPPIER_FEATURES_RATE_LIMIT_MAX=120 +HAPPIER_FEATURES_RATE_LIMIT_WINDOW=1 minute +HAPPIER_MACHINES_RATE_LIMIT_MAX=300 +HAPPIER_MACHINES_RATE_LIMIT_WINDOW=1 minute +HAPPIER_ARTIFACTS_RATE_LIMIT_MAX=300 +HAPPIER_ARTIFACTS_RATE_LIMIT_WINDOW=1 minute +HAPPIER_FEED_RATE_LIMIT_MAX=300 +HAPPIER_FEED_RATE_LIMIT_WINDOW=1 minute +HAPPIER_KV_LIST_RATE_LIMIT_MAX=600 +HAPPIER_KV_LIST_RATE_LIMIT_WINDOW=1 minute +HAPPIER_ACCOUNT_PROFILE_RATE_LIMIT_MAX=300 +HAPPIER_ACCOUNT_PROFILE_RATE_LIMIT_WINDOW=1 minute +HAPPIER_ACCOUNT_SETTINGS_RATE_LIMIT_MAX=300 +HAPPIER_ACCOUNT_SETTINGS_RATE_LIMIT_WINDOW=1 minute +HAPPIER_SESSION_PENDING_RATE_LIMIT_MAX=600 +HAPPIER_SESSION_PENDING_RATE_LIMIT_WINDOW=1 minute +HAPPIER_SESSION_PENDING_MATERIALIZE_RATE_LIMIT_MAX=120 +HAPPIER_SESSION_PENDING_MATERIALIZE_RATE_LIMIT_WINDOW=1 minute + ############################ # S3 / MinIO public file storage (required when HAPPIER_FILES_BACKEND=s3) ############################ @@ -70,6 +125,9 @@ HAPPIER_BUG_REPORTS_SERVER_DIAGNOSTICS_RATE_LIMIT_WINDOW=1 minute ############################ # OAuth / auth (optional) +# +# NOTE: If you enable OAuth providers and you are not using https://app.happier.dev, +# set HAPPIER_WEBAPP_URL or HAPPIER_WEBAPP_OAUTH_RETURN_URL_BASE so OAuth can redirect back to your client. ############################ # GitHub OAuth # GITHUB_CLIENT_ID= diff --git a/apps/server/prisma/migrations/20260219130000_add_encryption_mode/migration.sql b/apps/server/prisma/migrations/20260219130000_add_encryption_mode/migration.sql new file mode 100644 index 000000000..778180ddc --- /dev/null +++ b/apps/server/prisma/migrations/20260219130000_add_encryption_mode/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "encryptionMode" TEXT NOT NULL DEFAULT 'e2ee'; +ALTER TABLE "Account" ADD COLUMN "encryptionModeUpdatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "encryptionMode" TEXT NOT NULL DEFAULT 'e2ee'; + diff --git a/apps/server/prisma/migrations/20260220110000_make_session_share_dek_nullable/migration.sql b/apps/server/prisma/migrations/20260220110000_make_session_share_dek_nullable/migration.sql new file mode 100644 index 000000000..94cf8026c --- /dev/null +++ b/apps/server/prisma/migrations/20260220110000_make_session_share_dek_nullable/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "SessionShare" ALTER COLUMN "encryptedDataKey" DROP NOT NULL; + diff --git a/apps/server/prisma/migrations/20260220121500_make_public_session_share_dek_nullable/migration.sql b/apps/server/prisma/migrations/20260220121500_make_public_session_share_dek_nullable/migration.sql new file mode 100644 index 000000000..9b174588e --- /dev/null +++ b/apps/server/prisma/migrations/20260220121500_make_public_session_share_dek_nullable/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "PublicSessionShare" ALTER COLUMN "encryptedDataKey" DROP NOT NULL; diff --git a/apps/server/prisma/migrations/20260221142000_make_account_public_key_nullable/migration.sql b/apps/server/prisma/migrations/20260221142000_make_account_public_key_nullable/migration.sql new file mode 100644 index 000000000..df5eacaea --- /dev/null +++ b/apps/server/prisma/migrations/20260221142000_make_account_public_key_nullable/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Account" ALTER COLUMN "publicKey" DROP NOT NULL; + diff --git a/apps/server/prisma/mysql/migrations/20260219130000_add_encryption_mode/migration.sql b/apps/server/prisma/mysql/migrations/20260219130000_add_encryption_mode/migration.sql new file mode 100644 index 000000000..1e36ff8a7 --- /dev/null +++ b/apps/server/prisma/mysql/migrations/20260219130000_add_encryption_mode/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE `Account` ADD COLUMN `encryptionMode` VARCHAR(191) NOT NULL DEFAULT 'e2ee'; +-- Vitess/Planetscale compatibility: avoid non-constant defaults in ALTER TABLE. +ALTER TABLE `Account` ADD COLUMN `encryptionModeUpdatedAt` DATETIME(3) NOT NULL DEFAULT '1970-01-01 00:00:00.000'; +UPDATE `Account` SET `encryptionModeUpdatedAt` = `updatedAt` WHERE `encryptionModeUpdatedAt` = '1970-01-01 00:00:00.000'; + +-- AlterTable +ALTER TABLE `Session` ADD COLUMN `encryptionMode` VARCHAR(191) NOT NULL DEFAULT 'e2ee'; diff --git a/apps/server/prisma/mysql/migrations/20260220110000_make_session_share_dek_nullable/migration.sql b/apps/server/prisma/mysql/migrations/20260220110000_make_session_share_dek_nullable/migration.sql new file mode 100644 index 000000000..58d78bc4e --- /dev/null +++ b/apps/server/prisma/mysql/migrations/20260220110000_make_session_share_dek_nullable/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE `SessionShare` MODIFY `encryptedDataKey` LONGBLOB NULL; + diff --git a/apps/server/prisma/mysql/migrations/20260220121500_make_public_session_share_dek_nullable/migration.sql b/apps/server/prisma/mysql/migrations/20260220121500_make_public_session_share_dek_nullable/migration.sql new file mode 100644 index 000000000..8c73fd7b9 --- /dev/null +++ b/apps/server/prisma/mysql/migrations/20260220121500_make_public_session_share_dek_nullable/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `PublicSessionShare` MODIFY `encryptedDataKey` LONGBLOB NULL; diff --git a/apps/server/prisma/mysql/migrations/20260221142000_make_account_public_key_nullable/migration.sql b/apps/server/prisma/mysql/migrations/20260221142000_make_account_public_key_nullable/migration.sql new file mode 100644 index 000000000..6d33489c1 --- /dev/null +++ b/apps/server/prisma/mysql/migrations/20260221142000_make_account_public_key_nullable/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE `Account` MODIFY `publicKey` VARCHAR(191) NULL; + diff --git a/apps/server/prisma/mysql/schema.prisma b/apps/server/prisma/mysql/schema.prisma index 421ff0d38..f9d460593 100644 --- a/apps/server/prisma/mysql/schema.prisma +++ b/apps/server/prisma/mysql/schema.prisma @@ -7,6 +7,8 @@ generator client { provider = "prisma-client-js" + // Include Linux query engines so macOS-built release artifacts can run on Linux (self-host). + binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"] previewFeatures = ["metrics", "relationJoins"] output = "../../generated/mysql-client" } @@ -22,7 +24,7 @@ datasource db { model Account { id String @id @default(cuid()) - publicKey String @unique + publicKey String? @unique /// X25519 (NaCl box) public key for encrypting session DEKs to this account contentPublicKey Bytes? /// Ed25519 signature binding contentPublicKey to publicKey @@ -37,6 +39,8 @@ model Account { updatedAt DateTime @updatedAt settings String? @db.LongText settingsVersion Int @default(0) + encryptionMode String @default("e2ee") + encryptionModeUpdatedAt DateTime @default(now()) // Profile firstName String? @@ -233,6 +237,7 @@ model Session { tag String accountId String account Account @relation(fields: [accountId], references: [id]) + encryptionMode String @default("e2ee") metadata String @db.LongText metadataVersion Int @default(0) agentState String? @db.LongText @@ -682,7 +687,7 @@ model SessionShare { /// Whether this recipient can approve permission prompts for this session (delegated to the owner daemon) canApprovePermissions Boolean @default(false) /// NaCl Box encrypted dataEncryptionKey for the recipient - encryptedDataKey Bytes + encryptedDataKey Bytes? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accessLogs SessionShareAccessLog[] @@ -719,7 +724,7 @@ model PublicSessionShare { /// sha256(token) (32 bytes) tokenHash Bytes @db.VarBinary(32) @unique /// Encrypted dataEncryptionKey for public access - encryptedDataKey Bytes + encryptedDataKey Bytes? /// Optional expiration time (null = no expiration) expiresAt DateTime? /// Maximum number of uses (null = unlimited) diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index cd9b4f3ac..20fb7a475 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -21,7 +21,7 @@ datasource db { model Account { id String @id @default(cuid()) - publicKey String @unique + publicKey String? @unique /// X25519 (NaCl box) public key for encrypting session DEKs to this account contentPublicKey Bytes? /// Ed25519 signature binding contentPublicKey to publicKey @@ -36,6 +36,8 @@ model Account { updatedAt DateTime @updatedAt settings String? settingsVersion Int @default(0) + encryptionMode String @default("e2ee") + encryptionModeUpdatedAt DateTime @default(now()) // Profile firstName String? @@ -232,6 +234,7 @@ model Session { tag String accountId String account Account @relation(fields: [accountId], references: [id]) + encryptionMode String @default("e2ee") metadata String metadataVersion Int @default(0) agentState String? @@ -681,7 +684,7 @@ model SessionShare { /// Whether this recipient can approve permission prompts for this session (delegated to the owner daemon) canApprovePermissions Boolean @default(false) /// NaCl Box encrypted dataEncryptionKey for the recipient - encryptedDataKey Bytes + encryptedDataKey Bytes? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accessLogs SessionShareAccessLog[] @@ -718,7 +721,7 @@ model PublicSessionShare { /// sha256(token) (32 bytes) tokenHash Bytes @unique /// Encrypted dataEncryptionKey for public access - encryptedDataKey Bytes + encryptedDataKey Bytes? /// Optional expiration time (null = no expiration) expiresAt DateTime? /// Maximum number of uses (null = unlimited) diff --git a/apps/server/prisma/sqlite/migrations/20260219130000_add_encryption_mode/migration.sql b/apps/server/prisma/sqlite/migrations/20260219130000_add_encryption_mode/migration.sql new file mode 100644 index 000000000..8b0045813 --- /dev/null +++ b/apps/server/prisma/sqlite/migrations/20260219130000_add_encryption_mode/migration.sql @@ -0,0 +1,35 @@ +-- RedefineTables +-- +-- SQLite cannot add a column with a non-constant default via ALTER TABLE. +-- We need CURRENT_TIMESTAMP for encryptionModeUpdatedAt, so we redefine Account. +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Account" ( + "id" TEXT NOT NULL PRIMARY KEY, + "publicKey" TEXT NOT NULL, + "contentPublicKey" BLOB, + "contentPublicKeySig" BLOB, + "seq" INTEGER NOT NULL DEFAULT 0, + "changesFloor" INTEGER NOT NULL DEFAULT 0, + "feedSeq" BIGINT NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "settings" TEXT, + "settingsVersion" INTEGER NOT NULL DEFAULT 0, + "encryptionMode" TEXT NOT NULL DEFAULT 'e2ee', + "encryptionModeUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "firstName" TEXT, + "lastName" TEXT, + "username" TEXT, + "avatar" TEXT +); +INSERT INTO "new_Account" ("avatar", "changesFloor", "contentPublicKey", "contentPublicKeySig", "createdAt", "feedSeq", "firstName", "id", "lastName", "publicKey", "seq", "settings", "settingsVersion", "updatedAt", "username") SELECT "avatar", "changesFloor", "contentPublicKey", "contentPublicKeySig", "createdAt", "feedSeq", "firstName", "id", "lastName", "publicKey", "seq", "settings", "settingsVersion", "updatedAt", "username" FROM "Account"; +DROP TABLE "Account"; +ALTER TABLE "new_Account" RENAME TO "Account"; +CREATE UNIQUE INDEX "Account_publicKey_key" ON "Account"("publicKey"); +CREATE UNIQUE INDEX "Account_username_key" ON "Account"("username"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "encryptionMode" TEXT NOT NULL DEFAULT 'e2ee'; diff --git a/apps/server/prisma/sqlite/migrations/20260220110000_make_session_share_dek_nullable/migration.sql b/apps/server/prisma/sqlite/migrations/20260220110000_make_session_share_dek_nullable/migration.sql new file mode 100644 index 000000000..16fc1a554 --- /dev/null +++ b/apps/server/prisma/sqlite/migrations/20260220110000_make_session_share_dek_nullable/migration.sql @@ -0,0 +1,33 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; + +CREATE TABLE "new_SessionShare" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "sharedByUserId" TEXT NOT NULL, + "sharedWithUserId" TEXT NOT NULL, + "accessLevel" TEXT NOT NULL DEFAULT 'view', + "canApprovePermissions" BOOLEAN NOT NULL DEFAULT false, + "encryptedDataKey" BLOB, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "SessionShare_sharedByUserId_fkey" FOREIGN KEY ("sharedByUserId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "SessionShare_sharedWithUserId_fkey" FOREIGN KEY ("sharedWithUserId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +INSERT INTO "new_SessionShare" ("accessLevel", "canApprovePermissions", "createdAt", "encryptedDataKey", "id", "sessionId", "sharedByUserId", "sharedWithUserId", "updatedAt") +SELECT "accessLevel", "canApprovePermissions", "createdAt", "encryptedDataKey", "id", "sessionId", "sharedByUserId", "sharedWithUserId", "updatedAt" FROM "SessionShare"; + +DROP TABLE "SessionShare"; +ALTER TABLE "new_SessionShare" RENAME TO "SessionShare"; + +CREATE INDEX "SessionShare_sharedWithUserId_idx" ON "SessionShare"("sharedWithUserId"); +CREATE INDEX "SessionShare_sharedByUserId_idx" ON "SessionShare"("sharedByUserId"); +CREATE INDEX "SessionShare_sessionId_idx" ON "SessionShare"("sessionId"); +CREATE UNIQUE INDEX "SessionShare_sessionId_sharedWithUserId_key" ON "SessionShare"("sessionId", "sharedWithUserId"); + +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + diff --git a/apps/server/prisma/sqlite/migrations/20260220121500_make_public_session_share_dek_nullable/migration.sql b/apps/server/prisma/sqlite/migrations/20260220121500_make_public_session_share_dek_nullable/migration.sql new file mode 100644 index 000000000..02602d511 --- /dev/null +++ b/apps/server/prisma/sqlite/migrations/20260220121500_make_public_session_share_dek_nullable/migration.sql @@ -0,0 +1,33 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; + +CREATE TABLE "new_PublicSessionShare" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "createdByUserId" TEXT NOT NULL, + "tokenHash" BLOB NOT NULL, + "encryptedDataKey" BLOB, + "expiresAt" DATETIME, + "maxUses" INTEGER, + "useCount" INTEGER NOT NULL DEFAULT 0, + "isConsentRequired" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "PublicSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "PublicSessionShare_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +INSERT INTO "new_PublicSessionShare" ("createdAt", "createdByUserId", "encryptedDataKey", "expiresAt", "id", "isConsentRequired", "maxUses", "sessionId", "tokenHash", "updatedAt", "useCount") +SELECT "createdAt", "createdByUserId", "encryptedDataKey", "expiresAt", "id", "isConsentRequired", "maxUses", "sessionId", "tokenHash", "updatedAt", "useCount" FROM "PublicSessionShare"; + +DROP TABLE "PublicSessionShare"; +ALTER TABLE "new_PublicSessionShare" RENAME TO "PublicSessionShare"; + +CREATE UNIQUE INDEX "PublicSessionShare_sessionId_key" ON "PublicSessionShare"("sessionId"); +CREATE UNIQUE INDEX "PublicSessionShare_tokenHash_key" ON "PublicSessionShare"("tokenHash"); +CREATE INDEX "PublicSessionShare_tokenHash_idx" ON "PublicSessionShare"("tokenHash"); +CREATE INDEX "PublicSessionShare_sessionId_idx" ON "PublicSessionShare"("sessionId"); + +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/server/prisma/sqlite/migrations/20260221142000_make_account_public_key_nullable/migration.sql b/apps/server/prisma/sqlite/migrations/20260221142000_make_account_public_key_nullable/migration.sql new file mode 100644 index 000000000..6d574d5ea --- /dev/null +++ b/apps/server/prisma/sqlite/migrations/20260221142000_make_account_public_key_nullable/migration.sql @@ -0,0 +1,33 @@ +-- RedefineTables +-- +-- SQLite cannot drop NOT NULL constraints via ALTER TABLE. +-- We redefine Account to make publicKey nullable. +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Account" ( + "id" TEXT NOT NULL PRIMARY KEY, + "publicKey" TEXT, + "contentPublicKey" BLOB, + "contentPublicKeySig" BLOB, + "seq" INTEGER NOT NULL DEFAULT 0, + "changesFloor" INTEGER NOT NULL DEFAULT 0, + "feedSeq" BIGINT NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "settings" TEXT, + "settingsVersion" INTEGER NOT NULL DEFAULT 0, + "encryptionMode" TEXT NOT NULL DEFAULT 'e2ee', + "encryptionModeUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "firstName" TEXT, + "lastName" TEXT, + "username" TEXT, + "avatar" TEXT +); +INSERT INTO "new_Account" ("avatar", "changesFloor", "contentPublicKey", "contentPublicKeySig", "createdAt", "feedSeq", "firstName", "id", "lastName", "publicKey", "seq", "settings", "settingsVersion", "updatedAt", "username") SELECT "avatar", "changesFloor", "contentPublicKey", "contentPublicKeySig", "createdAt", "feedSeq", "firstName", "id", "lastName", "publicKey", "seq", "settings", "settingsVersion", "updatedAt", "username" FROM "Account"; +DROP TABLE "Account"; +ALTER TABLE "new_Account" RENAME TO "Account"; +CREATE UNIQUE INDEX "Account_publicKey_key" ON "Account"("publicKey"); +CREATE UNIQUE INDEX "Account_username_key" ON "Account"("username"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + diff --git a/apps/server/prisma/sqlite/schema.prisma b/apps/server/prisma/sqlite/schema.prisma index ec9863372..f7939bc29 100644 --- a/apps/server/prisma/sqlite/schema.prisma +++ b/apps/server/prisma/sqlite/schema.prisma @@ -7,6 +7,8 @@ generator client { provider = "prisma-client-js" + // Include Linux query engines so macOS-built release artifacts can run on Linux (self-host). + binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"] previewFeatures = ["metrics"] output = "../../generated/sqlite-client" } @@ -22,7 +24,7 @@ datasource db { model Account { id String @id @default(cuid()) - publicKey String @unique + publicKey String? @unique /// X25519 (NaCl box) public key for encrypting session DEKs to this account contentPublicKey Bytes? /// Ed25519 signature binding contentPublicKey to publicKey @@ -37,6 +39,8 @@ model Account { updatedAt DateTime @updatedAt settings String? settingsVersion Int @default(0) + encryptionMode String @default("e2ee") + encryptionModeUpdatedAt DateTime @default(now()) // Profile firstName String? @@ -233,6 +237,7 @@ model Session { tag String accountId String account Account @relation(fields: [accountId], references: [id]) + encryptionMode String @default("e2ee") metadata String metadataVersion Int @default(0) agentState String? @@ -682,7 +687,7 @@ model SessionShare { /// Whether this recipient can approve permission prompts for this session (delegated to the owner daemon) canApprovePermissions Boolean @default(false) /// NaCl Box encrypted dataEncryptionKey for the recipient - encryptedDataKey Bytes + encryptedDataKey Bytes? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accessLogs SessionShareAccessLog[] @@ -719,7 +724,7 @@ model PublicSessionShare { /// sha256(token) (32 bytes) tokenHash Bytes @unique /// Encrypted dataEncryptionKey for public access - encryptedDataKey Bytes + encryptedDataKey Bytes? /// Optional expiration time (null = no expiration) expiresAt DateTime? /// Maximum number of uses (null = unlimited) diff --git a/apps/server/scripts/generateClients.ts b/apps/server/scripts/generateClients.ts index f0c034485..1e99f6617 100644 --- a/apps/server/scripts/generateClients.ts +++ b/apps/server/scripts/generateClients.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { pathToFileURL } from "node:url"; +import { createRequire } from "node:module"; export type BuildDbProvider = "postgres" | "mysql" | "sqlite"; @@ -100,17 +101,23 @@ async function main(): Promise<void> { await run("yarn", ["-s", "schema:sync", "--quiet"], env); + const require = createRequire(import.meta.url); + const prismaCliPath = require.resolve("prisma/build/index.js"); + // Always generate the default client (postgres schema). - await run("yarn", ["-s", "prisma", "generate"], { ...env, DATABASE_URL: prismaGenerateDatabaseUrlForProvider("postgres") }); + await run(process.execPath, [prismaCliPath, "generate"], { + ...env, + DATABASE_URL: prismaGenerateDatabaseUrlForProvider("postgres"), + }); if (providers.has("sqlite")) { - await run("yarn", ["-s", "prisma", "generate", "--schema", "prisma/sqlite/schema.prisma"], { + await run(process.execPath, [prismaCliPath, "generate", "--schema", "prisma/sqlite/schema.prisma"], { ...env, DATABASE_URL: prismaGenerateDatabaseUrlForProvider("sqlite"), }); } if (providers.has("mysql")) { - await run("yarn", ["-s", "prisma", "generate", "--schema", "prisma/mysql/schema.prisma"], { + await run(process.execPath, [prismaCliPath, "generate", "--schema", "prisma/mysql/schema.prisma"], { ...env, DATABASE_URL: prismaGenerateDatabaseUrlForProvider("mysql"), }); diff --git a/apps/server/scripts/schemaSync.spec.ts b/apps/server/scripts/schemaSync.spec.ts index 58f1e95ef..99dca0ecb 100644 --- a/apps/server/scripts/schemaSync.spec.ts +++ b/apps/server/scripts/schemaSync.spec.ts @@ -25,6 +25,32 @@ model Account { id String @id } expect(mysql).toContain('provider = "mysql"'); }); + it("includes linux binaryTargets in sqlite/mysql generator blocks (cross-compiled server binaries)", () => { + const master = ` +generator client { + provider = "prisma-client-js" + previewFeatures = ["metrics", "relationJoins"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Account { id String @id } +`; + + const sqlite = generateSqliteSchemaFromPostgres(master); + expect(sqlite).toMatch( + /binaryTargets\s*=\s*\["native",\s*"debian-openssl-3\.0\.x",\s*"linux-arm64-openssl-3\.0\.x"\]/, + ); + + const mysql = generateMySqlSchemaFromPostgres(master); + expect(mysql).toMatch( + /binaryTargets\s*=\s*\["native",\s*"debian-openssl-3\.0\.x",\s*"linux-arm64-openssl-3\.0\.x"\]/, + ); + }); + it("pins MySQL-indexed sha256 token hashes to VARBINARY(32)", () => { const master = ` generator client { diff --git a/apps/server/scripts/schemaSync.ts b/apps/server/scripts/schemaSync.ts index 51e5f5aff..dbbd00856 100644 --- a/apps/server/scripts/schemaSync.ts +++ b/apps/server/scripts/schemaSync.ts @@ -121,6 +121,8 @@ function generateProviderSchemaFromPostgres( const generatorClient = [ 'generator client {', ' provider = "prisma-client-js"', + ' // Include Linux query engines so macOS-built release artifacts can run on Linux (self-host).', + ' binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"]', ` previewFeatures = [${opts.previewFeatures.map((v) => JSON.stringify(v)).join(", ")}]`, ` output = "${opts.output}"`, '}', diff --git a/apps/server/sources/app/api/api.ts b/apps/server/sources/app/api/api.ts index 03a0fe8d5..051d77328 100644 --- a/apps/server/sources/app/api/api.ts +++ b/apps/server/sources/app/api/api.ts @@ -29,6 +29,7 @@ import { featuresRoutes } from "./routes/features/featuresRoutes"; import { sessionPendingRoutes } from "./routes/session/pendingRoutes"; import { bugReportDiagnosticsRoutes } from "./routes/diagnostics/bugReportDiagnosticsRoutes"; import { automationRoutes } from "./routes/automations/automationRoutes"; +import { resolveApiRateLimitPluginOptions, resolveApiTrustProxy } from "./utils/apiRateLimitPolicy"; export function resolveApiListenHost(env: Record<string, string | undefined>): string { const host = (env.HAPPIER_SERVER_HOST ?? env.HAPPY_SERVER_HOST ?? '').toString().trim(); @@ -41,9 +42,11 @@ export async function startApi() { log('Starting API...'); // Start API + const trustProxy = resolveApiTrustProxy(process.env); const app = fastify({ loggerInstance: logger, bodyLimit: 1024 * 1024 * 100, // 100MB + ...(typeof trustProxy !== "undefined" ? { trustProxy } : null), }); app.register(import('@fastify/cors'), { origin: '*', @@ -52,9 +55,7 @@ export async function startApi() { allowedHeaders: ['authorization', 'content-type'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] }); - app.register(import('@fastify/rate-limit'), { - global: false // Only apply to routes with explicit config - }); + app.register(import('@fastify/rate-limit'), resolveApiRateLimitPluginOptions(process.env)); enableOptionalStatics(app); diff --git a/apps/server/sources/app/api/routes/account/accountRoutes.encryption.integration.spec.ts b/apps/server/sources/app/api/routes/account/accountRoutes.encryption.integration.spec.ts new file mode 100644 index 000000000..a0b8ccb09 --- /dev/null +++ b/apps/server/sources/app/api/routes/account/accountRoutes.encryption.integration.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakeRouteApp, createReplyStub, getRouteHandler } from "../../testkit/routeHarness"; + +let dbAccountFindUnique: any; +let dbAccountUpdate: any; + +vi.mock("@/storage/db", () => ({ + db: { + account: { + findUnique: (...args: any[]) => dbAccountFindUnique(...args), + update: (...args: any[]) => dbAccountUpdate(...args), + }, + }, +})); + +describe("accountRoutes (encryption mode integration)", () => { + it("GET /v1/account/encryption returns account encryption mode", async () => { + dbAccountFindUnique = vi.fn(async () => ({ + encryptionMode: "e2ee", + encryptionModeUpdatedAt: new Date("2026-02-17T10:00:00.000Z"), + })); + + const { accountRoutes } = await import("./accountRoutes"); + const app = createFakeRouteApp(); + accountRoutes(app as any); + + const handler = getRouteHandler(app, "GET", "/v1/account/encryption"); + const reply = createReplyStub(); + + const response = await handler({ userId: "u1" }, reply); + + expect(response).toEqual({ mode: "e2ee", updatedAt: 1771322400000 }); + }); + + it("PATCH /v1/account/encryption returns 404 when account opt-out is disabled", async () => { + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + process.env.HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT = "0"; + + dbAccountUpdate = vi.fn(async () => ({ + encryptionMode: "plain", + encryptionModeUpdatedAt: new Date("2026-02-17T10:00:00.000Z"), + })); + + const { accountRoutes } = await import("./accountRoutes"); + const app = createFakeRouteApp(); + accountRoutes(app as any); + + const handler = getRouteHandler(app, "PATCH", "/v1/account/encryption"); + const reply = createReplyStub(); + + const response = await handler({ userId: "u1", body: { mode: "plain" } }, reply); + + expect(response).toBeUndefined(); + expect(reply.code).toHaveBeenCalledWith(404); + expect(reply.send).toHaveBeenCalledWith({ error: "not_found" }); + expect(dbAccountUpdate).not.toHaveBeenCalled(); + }); + + it("PATCH /v1/account/encryption updates the account mode when account opt-out is enabled", async () => { + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + process.env.HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT = "1"; + + dbAccountUpdate = vi.fn(async () => ({ + encryptionMode: "plain", + encryptionModeUpdatedAt: new Date("2026-02-17T11:00:00.000Z"), + })); + + const { accountRoutes } = await import("./accountRoutes"); + const app = createFakeRouteApp(); + accountRoutes(app as any); + + const handler = getRouteHandler(app, "PATCH", "/v1/account/encryption"); + const reply = createReplyStub(); + + const response = await handler({ userId: "u1", body: { mode: "plain" } }, reply); + + expect(dbAccountUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "u1" }, + data: expect.objectContaining({ encryptionMode: "plain", encryptionModeUpdatedAt: expect.any(Date) }), + }), + ); + expect(response).toEqual({ mode: "plain", updatedAt: 1771326000000 }); + }); +}); diff --git a/apps/server/sources/app/api/routes/account/accountRoutes.encryption.keylessRejectE2ee.feat.e2ee.keylessAccounts.integration.spec.ts b/apps/server/sources/app/api/routes/account/accountRoutes.encryption.keylessRejectE2ee.feat.e2ee.keylessAccounts.integration.spec.ts new file mode 100644 index 000000000..696d3b092 --- /dev/null +++ b/apps/server/sources/app/api/routes/account/accountRoutes.encryption.keylessRejectE2ee.feat.e2ee.keylessAccounts.integration.spec.ts @@ -0,0 +1,154 @@ +import Fastify from "fastify"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod"; + +import { initDbSqlite, db } from "@/storage/db"; +import { applyLightDefaultEnv, ensureHandyMasterSecret } from "@/flavors/light/env"; +import { registerAccountEncryptionRoutes } from "./registerAccountEncryptionRoutes"; + +function runServerPrismaMigrateDeploySqlite(params: { cwd: string; env: NodeJS.ProcessEnv }): void { + const res = spawnSync( + "yarn", + ["-s", "prisma", "migrate", "deploy", "--schema", "prisma/sqlite/schema.prisma"], + { + cwd: params.cwd, + env: { ...(params.env as Record<string, string>), RUST_LOG: "info" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (res.status !== 0) { + const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`.trim(); + throw new Error(`prisma migrate deploy failed (status=${res.status}). ${out}`); + } +} + +function createTestApp() { + const app = Fastify({ logger: false }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + const typed = app.withTypeProvider<ZodTypeProvider>() as any; + + typed.decorate("authenticate", async (request: any, reply: any) => { + const userId = request.headers["x-test-user-id"]; + if (typeof userId !== "string" || !userId) { + return reply.code(401).send({ error: "Unauthorized" }); + } + request.userId = userId; + }); + + return typed; +} + +describe("registerAccountEncryptionRoutes (keyless accounts) (integration)", () => { + const envBackup = { ...process.env }; + let testEnvBase: NodeJS.ProcessEnv; + let baseDir: string; + + const restoreEnv = (base: NodeJS.ProcessEnv) => { + for (const key of Object.keys(process.env)) { + if (!(key in base)) { + delete (process.env as any)[key]; + } + } + for (const [key, value] of Object.entries(base)) { + if (typeof value === "string") { + process.env[key] = value; + } + } + }; + + beforeAll(async () => { + baseDir = await mkdtemp(join(tmpdir(), "happier-account-encryption-keyless-")); + const dbPath = join(baseDir, "test.sqlite"); + + process.env = { + ...process.env, + HAPPIER_DB_PROVIDER: "sqlite", + HAPPY_DB_PROVIDER: "sqlite", + DATABASE_URL: `file:${dbPath}`, + HAPPY_SERVER_LIGHT_DATA_DIR: baseDir, + HAPPIER_SERVER_LIGHT_DATA_DIR: baseDir, + }; + applyLightDefaultEnv(process.env); + await ensureHandyMasterSecret(process.env); + testEnvBase = { ...process.env }; + + runServerPrismaMigrateDeploySqlite({ cwd: process.cwd(), env: process.env }); + await initDbSqlite(); + await db.$connect(); + }, 120_000); + + afterEach(async () => { + restoreEnv(testEnvBase); + await db.accountIdentity.deleteMany().catch(() => {}); + await db.account.deleteMany().catch(() => {}); + }); + + afterAll(async () => { + await db.$disconnect(); + restoreEnv(envBackup); + await rm(baseDir, { recursive: true, force: true }); + }); + + it("rejects switching to e2ee when the account is keyless (publicKey is null)", async () => { + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + process.env.HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT = "1"; + + const account = await db.account.create({ + data: { publicKey: null, encryptionMode: "plain" }, + select: { id: true }, + }); + + const app = createTestApp(); + registerAccountEncryptionRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "PATCH", + url: "/v1/account/encryption", + headers: { "content-type": "application/json", "x-test-user-id": account.id }, + payload: { mode: "e2ee" }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid-params" }); + + const stored = await db.account.findUnique({ + where: { id: account.id }, + select: { encryptionMode: true }, + }); + expect(stored?.encryptionMode).toBe("plain"); + + await app.close(); + }); + + it("treats keyless accounts as plain on GET even if legacy rows store encryptionMode=e2ee", async () => { + const account = await db.account.create({ + data: { publicKey: null, encryptionMode: "e2ee" }, + select: { id: true, encryptionModeUpdatedAt: true }, + }); + + const app = createTestApp(); + registerAccountEncryptionRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "GET", + url: "/v1/account/encryption", + headers: { "x-test-user-id": account.id }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + mode: "plain", + updatedAt: account.encryptionModeUpdatedAt.getTime(), + }); + + await app.close(); + }); +}); diff --git a/apps/server/sources/app/api/routes/account/accountRoutes.rateLimit.spec.ts b/apps/server/sources/app/api/routes/account/accountRoutes.rateLimit.spec.ts new file mode 100644 index 000000000..88da7d774 --- /dev/null +++ b/apps/server/sources/app/api/routes/account/accountRoutes.rateLimit.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; + +class FakeApp { + public authenticate = vi.fn(); + public routes = new Map<string, any>(); + + get(path: string, opts: any, _handler: any) { + this.routes.set(`GET ${path}`, { opts }); + } + post(path: string, opts: any, _handler: any) { + this.routes.set(`POST ${path}`, { opts }); + } + patch(path: string, opts: any, _handler: any) { + this.routes.set(`PATCH ${path}`, { opts }); + } +} + +describe("accountRoutes rate limits", () => { + it("registers hot account endpoints with explicit rate limits", async () => { + const { accountRoutes } = await import("./accountRoutes"); + const app = new FakeApp(); + accountRoutes(app as any); + + const profile = app.routes.get("GET /v1/account/profile"); + expect(profile?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + + const settingsGet = app.routes.get("GET /v1/account/settings"); + expect(settingsGet?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/account/accountRoutes.ts b/apps/server/sources/app/api/routes/account/accountRoutes.ts index 45960ccae..f5d4cde68 100644 --- a/apps/server/sources/app/api/routes/account/accountRoutes.ts +++ b/apps/server/sources/app/api/routes/account/accountRoutes.ts @@ -4,11 +4,13 @@ import { registerAccountIdentityVisibilityRoute } from "./registerAccountIdentit import { registerAccountUsernameRoute } from "./registerAccountUsernameRoute"; import { registerAccountSettingsRoutes } from "./registerAccountSettingsRoutes"; import { registerAccountUsageRoutes } from "./registerAccountUsageRoutes"; +import { registerAccountEncryptionRoutes } from "./registerAccountEncryptionRoutes"; export function accountRoutes(app: Fastify): void { registerAccountProfileRoute(app); registerAccountIdentityVisibilityRoute(app); registerAccountUsernameRoute(app); registerAccountSettingsRoutes(app); + registerAccountEncryptionRoutes(app); registerAccountUsageRoutes(app); } diff --git a/apps/server/sources/app/api/routes/account/registerAccountEncryptionRoutes.ts b/apps/server/sources/app/api/routes/account/registerAccountEncryptionRoutes.ts new file mode 100644 index 000000000..32c4b60a4 --- /dev/null +++ b/apps/server/sources/app/api/routes/account/registerAccountEncryptionRoutes.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { db } from "@/storage/db"; +import { createServerFeatureGatePreHandler } from "@/app/features/catalog/serverFeatureGate"; +import { + AccountEncryptionModeResponseSchema, + AccountEncryptionModeUpdateRequestSchema, +} from "@happier-dev/protocol"; +import { type Fastify } from "../../types"; + +export function registerAccountEncryptionRoutes(app: Fastify): void { + app.get( + "/v1/account/encryption", + { + preHandler: app.authenticate, + schema: { + response: { + 200: AccountEncryptionModeResponseSchema, + 500: z.object({ error: z.literal("internal") }), + }, + }, + }, + async (request, reply) => { + try { + const user = await db.account.findUnique({ + where: { id: request.userId }, + select: { encryptionMode: true, encryptionModeUpdatedAt: true, publicKey: true }, + }); + if (!user) { + return reply.code(500).send({ error: "internal" }); + } + + const mode = !user.publicKey ? "plain" : user.encryptionMode === "plain" ? "plain" : "e2ee"; + return reply.send({ mode, updatedAt: user.encryptionModeUpdatedAt.getTime() }); + } catch { + return reply.code(500).send({ error: "internal" }); + } + }, + ); + + app.patch( + "/v1/account/encryption", + { + preHandler: [createServerFeatureGatePreHandler("encryption.accountOptOut"), app.authenticate], + schema: { + body: AccountEncryptionModeUpdateRequestSchema, + response: { + 200: AccountEncryptionModeResponseSchema, + 400: z.object({ error: z.literal("invalid-params") }), + 404: z.object({ error: z.literal("not_found") }), + 500: z.object({ error: z.literal("internal") }), + }, + }, + }, + async (request, reply) => { + const requestedMode = request.body.mode; + const mode = requestedMode === "plain" ? "plain" : "e2ee"; + + try { + if (mode === "e2ee") { + const account = await db.account.findUnique({ + where: { id: request.userId }, + select: { publicKey: true }, + }); + if (!account) { + return reply.code(500).send({ error: "internal" }); + } + if (!account.publicKey) { + return reply.code(400).send({ error: "invalid-params" }); + } + } + + const updated = await db.account.update({ + where: { id: request.userId }, + data: { encryptionMode: mode, encryptionModeUpdatedAt: new Date() }, + select: { encryptionMode: true, encryptionModeUpdatedAt: true }, + }); + + const storedMode = updated.encryptionMode === "plain" ? "plain" : "e2ee"; + return reply.send({ mode: storedMode, updatedAt: updated.encryptionModeUpdatedAt.getTime() }); + } catch { + return reply.code(500).send({ error: "internal" }); + } + }, + ); +} diff --git a/apps/server/sources/app/api/routes/account/registerAccountProfileRoute.ts b/apps/server/sources/app/api/routes/account/registerAccountProfileRoute.ts index 1fb8c36a3..ef91361d0 100644 --- a/apps/server/sources/app/api/routes/account/registerAccountProfileRoute.ts +++ b/apps/server/sources/app/api/routes/account/registerAccountProfileRoute.ts @@ -5,10 +5,19 @@ import { type Fastify } from "../../types"; import { isServerFeatureEnabledForRequest } from "@/app/features/catalog/serverFeatureGate"; import { ConnectedServiceIdSchema } from "@happier-dev/protocol"; import { isConnectedServiceCredentialMetadataV2 } from "../connect/connectedServicesV2/credentialMetadataV2"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; export function registerAccountProfileRoute(app: Fastify): void { app.get('/v1/account/profile', { preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_ACCOUNT_PROFILE_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_ACCOUNT_PROFILE_RATE_LIMIT_WINDOW", + defaultMax: 300, + defaultWindow: "1 minute", + }), + }, }, async (request, reply) => { const userId = request.userId; const user = await db.account.findUniqueOrThrow({ diff --git a/apps/server/sources/app/api/routes/account/registerAccountSettingsRoutes.ts b/apps/server/sources/app/api/routes/account/registerAccountSettingsRoutes.ts index 14e30aedc..0104c1126 100644 --- a/apps/server/sources/app/api/routes/account/registerAccountSettingsRoutes.ts +++ b/apps/server/sources/app/api/routes/account/registerAccountSettingsRoutes.ts @@ -6,11 +6,20 @@ import { log } from "@/utils/logging/log"; import { afterTx, inTx } from "@/storage/inTx"; import { markAccountChanged } from "@/app/changes/markAccountChanged"; import { type Fastify } from "../../types"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; export function registerAccountSettingsRoutes(app: Fastify): void { // Get Account Settings API app.get('/v1/account/settings', { preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_ACCOUNT_SETTINGS_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_ACCOUNT_SETTINGS_RATE_LIMIT_WINDOW", + defaultMax: 300, + defaultWindow: "1 minute", + }), + }, schema: { response: { 200: z.object({ diff --git a/apps/server/sources/app/api/routes/artifacts/artifactsRoutes.rateLimit.spec.ts b/apps/server/sources/app/api/routes/artifacts/artifactsRoutes.rateLimit.spec.ts new file mode 100644 index 000000000..9fc5ae5ba --- /dev/null +++ b/apps/server/sources/app/api/routes/artifacts/artifactsRoutes.rateLimit.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +class FakeApp { + public authenticate = vi.fn(); + public routes = new Map<string, any>(); + + get(path: string, opts: any, _handler: any) { + this.routes.set(`GET ${path}`, { opts }); + } + post(path: string, opts: any, _handler: any) { + this.routes.set(`POST ${path}`, { opts }); + } + delete(path: string, opts: any, _handler: any) { + this.routes.set(`DELETE ${path}`, { opts }); + } +} + +describe("artifactsRoutes rate limits", () => { + it("registers GET /v1/artifacts with an explicit rate limit", async () => { + const { artifactsRoutes } = await import("./artifactsRoutes"); + const app = new FakeApp(); + artifactsRoutes(app as any); + + const route = app.routes.get("GET /v1/artifacts"); + expect(route?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/artifacts/artifactsRoutes.ts b/apps/server/sources/app/api/routes/artifacts/artifactsRoutes.ts index f86ccc41b..85acf4456 100644 --- a/apps/server/sources/app/api/routes/artifacts/artifactsRoutes.ts +++ b/apps/server/sources/app/api/routes/artifacts/artifactsRoutes.ts @@ -6,11 +6,20 @@ import { randomKeyNaked } from "@/utils/keys/randomKeyNaked"; import { log } from "@/utils/logging/log"; import * as privacyKit from "privacy-kit"; import { createArtifact, deleteArtifact, updateArtifact } from "@/app/artifacts/artifactWriteService"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; export function artifactsRoutes(app: Fastify) { // GET /v1/artifacts - List all artifacts for the account app.get('/v1/artifacts', { preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_ARTIFACTS_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_ARTIFACTS_RATE_LIMIT_WINDOW", + defaultMax: 300, + defaultWindow: "1 minute", + }), + }, schema: { response: { 200: z.array(z.object({ @@ -63,6 +72,14 @@ export function artifactsRoutes(app: Fastify) { // GET /v1/artifacts/:id - Get single artifact with full body app.get('/v1/artifacts/:id', { preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_ARTIFACTS_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_ARTIFACTS_RATE_LIMIT_WINDOW", + defaultMax: 300, + defaultWindow: "1 minute", + }), + }, schema: { params: z.object({ id: z.string() diff --git a/apps/server/sources/app/api/routes/auth/authRoutes.mtls.feat.auth.mtls.integration.spec.ts b/apps/server/sources/app/api/routes/auth/authRoutes.mtls.feat.auth.mtls.integration.spec.ts new file mode 100644 index 000000000..fe9c62910 --- /dev/null +++ b/apps/server/sources/app/api/routes/auth/authRoutes.mtls.feat.auth.mtls.integration.spec.ts @@ -0,0 +1,558 @@ +import Fastify from "fastify"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod"; + +import { initDbSqlite, db } from "@/storage/db"; +import { applyLightDefaultEnv, ensureHandyMasterSecret } from "@/flavors/light/env"; +import { auth } from "@/app/auth/auth"; +import { authRoutes } from "./authRoutes"; +import { createAppCloseTracker } from "../../testkit/appLifecycle"; +import { readAuthMtlsFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; + +const { trackApp, closeTrackedApps } = createAppCloseTracker(); + +function runServerPrismaMigrateDeploySqlite(params: { cwd: string; env: NodeJS.ProcessEnv }): void { + const res = spawnSync( + "yarn", + ["-s", "prisma", "migrate", "deploy", "--schema", "prisma/sqlite/schema.prisma"], + { + cwd: params.cwd, + env: { ...(params.env as Record<string, string>), RUST_LOG: "info" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (res.status !== 0) { + const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`.trim(); + throw new Error(`prisma migrate deploy failed (status=${res.status}). ${out}`); + } +} + +function createTestApp() { + const app = Fastify({ logger: false }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + const typed = app.withTypeProvider<ZodTypeProvider>() as any; + return trackApp(typed); +} + +describe("authRoutes (mTLS) (integration)", () => { + const envBackup = { ...process.env }; + let testEnvBase: NodeJS.ProcessEnv; + let baseDir: string; + + beforeAll(async () => { + baseDir = await mkdtemp(join(tmpdir(), "happier-auth-mtls-")); + const dbPath = join(baseDir, "test.sqlite"); + + process.env = { + ...process.env, + HAPPIER_DB_PROVIDER: "sqlite", + HAPPY_DB_PROVIDER: "sqlite", + DATABASE_URL: `file:${dbPath}`, + HAPPY_SERVER_LIGHT_DATA_DIR: baseDir, + }; + applyLightDefaultEnv(process.env); + await ensureHandyMasterSecret(process.env); + testEnvBase = { ...process.env }; + + runServerPrismaMigrateDeploySqlite({ cwd: process.cwd(), env: process.env }); + await initDbSqlite(); + await db.$connect(); + await auth.init(); + }, 120_000); + + const restoreEnv = (base: NodeJS.ProcessEnv) => { + for (const key of Object.keys(process.env)) { + if (!(key in base)) { + delete (process.env as any)[key]; + } + } + for (const [key, value] of Object.entries(base)) { + if (typeof value === "string") { + process.env[key] = value; + } + } + }; + + afterEach(async () => { + await closeTrackedApps(); + restoreEnv(testEnvBase); + vi.unstubAllGlobals(); + await db.accountIdentity.deleteMany().catch(() => {}); + await db.account.deleteMany().catch(() => {}); + }); + + afterAll(async () => { + await db.$disconnect(); + restoreEnv(envBackup); + await rm(baseDir, { recursive: true, force: true }); + }); + + it("auto-provisions a keyless account and returns a bearer token (forwarded mode)", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "plain", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS: "cn=example root ca", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_FINGERPRINT_HEADER: "x-happier-client-cert-sha256", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER: "x-happier-client-cert-issuer", + }); + expect(readAuthMtlsFeatureEnv(process.env).allowedIssuers).toEqual(["cn=example root ca"]); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/mtls", + headers: { + "x-happier-client-cert-email": "alice@example.com", + "x-happier-client-cert-sha256": "sha256:abc123", + "x-happier-client-cert-issuer": " CN=Example Root CA ", + }, + }); + + expect(res.statusCode, res.body).toBe(200); + const body = res.json() as any; + expect(body.success).toBe(true); + expect(typeof body.token).toBe("string"); + expect(body.token.length).toBeGreaterThan(10); + + const accounts = await db.account.findMany({ + include: { AccountIdentity: { orderBy: { provider: "asc" } } }, + orderBy: { createdAt: "asc" }, + }); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.publicKey).toBeNull(); + expect(accounts[0]?.AccountIdentity?.[0]?.provider).toBe("mtls"); + expect(accounts[0]?.AccountIdentity?.[0]?.providerUserId).toBe("alice@example.com"); + + await app.close(); + }); + + it("rejects a forwarded identity when an issuer allowlist is configured and the issuer does not match", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "plain", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS: "cn=trusted ca", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER: "x-happier-client-cert-issuer", + }); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/mtls", + headers: { + "x-happier-client-cert-email": "alice@example.com", + "x-happier-client-cert-issuer": "CN=Untrusted CA", + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ error: "not-eligible" }); + + await app.close(); + }); + + it("accepts an issuer allowlist match when the forwarded issuer is a full DN (CN extracted)", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "plain", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS: "Example Root CA", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER: "x-happier-client-cert-issuer", + }); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/mtls", + headers: { + "x-happier-client-cert-email": "alice@example.com", + "x-happier-client-cert-issuer": "C=US, O=Example Corp, CN=Example Root CA", + }, + }); + + expect(res.statusCode, res.body).toBe(200); + + await app.close(); + }); + + it("rejects issuer allowlist entries that are full DNs when the forwarded issuer has the same CN but a different DN", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "plain", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + // Full DN allowlist entry (intended to be exact-match, not just CN-match). + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS: "C=US, O=Example Corp, CN=Example Root CA", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER: "x-happier-client-cert-issuer", + }); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/mtls", + headers: { + "x-happier-client-cert-email": "alice@example.com", + // Same CN but different organization. + "x-happier-client-cert-issuer": "C=US, O=Other Corp, CN=Example Root CA", + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ error: "not-eligible" }); + + await app.close(); + }); + + it("enforces allowed email domains when identitySource=san_upn", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "plain", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_upn", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS: "cn=example root ca", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_UPN_HEADER: "x-happier-client-cert-upn", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER: "x-happier-client-cert-issuer", + }); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/mtls", + headers: { + "x-happier-client-cert-upn": "alice@evil.example", + "x-happier-client-cert-issuer": "CN=Example Root CA", + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ error: "not-eligible" }); + + await app.close(); + }); + + it("supports browser handoff via /start -> /complete -> /claim (forwarded mode)", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "plain", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS: "cn=example root ca", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_FINGERPRINT_HEADER: "x-happier-client-cert-sha256", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER: "x-happier-client-cert-issuer", + HAPPIER_FEATURE_AUTH_MTLS__RETURN_TO_ALLOW_PREFIXES: "happier://", + }); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const startRes = await app.inject({ + method: "GET", + url: "/v1/auth/mtls/start?returnTo=" + encodeURIComponent("happier://auth/return"), + }); + expect(startRes.statusCode).toBe(302); + const completeUrl = String(startRes.headers.location ?? ""); + expect(completeUrl).toContain("/v1/auth/mtls/complete"); + + const completeRes = await app.inject({ + method: "GET", + url: completeUrl, + headers: { + "x-happier-client-cert-email": "alice@example.com", + "x-happier-client-cert-sha256": "sha256:abc123", + "x-happier-client-cert-issuer": "CN=Example Root CA", + }, + }); + expect(completeRes.statusCode, completeRes.body).toBe(302); + const returnUrl = String(completeRes.headers.location ?? ""); + const parsed = new URL(returnUrl); + expect(parsed.protocol).toBe("happier:"); + const code = parsed.searchParams.get("code"); + expect(typeof code).toBe("string"); + expect(code?.length ?? 0).toBeGreaterThan(10); + + const claimRes = await app.inject({ + method: "POST", + url: "/v1/auth/mtls/claim", + payload: { code }, + }); + expect(claimRes.statusCode).toBe(200); + const claimBody = claimRes.json() as any; + expect(claimBody.success).toBe(true); + expect(typeof claimBody.token).toBe("string"); + + // Claim codes must be single-use to avoid replay within the TTL window. + const claimRes2 = await app.inject({ + method: "POST", + url: "/v1/auth/mtls/claim", + payload: { code }, + }); + expect(claimRes2.statusCode).toBe(401); + expect(claimRes2.json()).toEqual({ error: "invalid-code" }); + + await app.close(); + }); + + it("allows only one successful /claim even under concurrent attempts", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "plain", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS: "cn=example root ca", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER: "x-happier-client-cert-issuer", + HAPPIER_FEATURE_AUTH_MTLS__RETURN_TO_ALLOW_PREFIXES: "happier://", + }); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const startRes = await app.inject({ + method: "GET", + url: "/v1/auth/mtls/start?returnTo=" + encodeURIComponent("happier://auth/return"), + }); + expect(startRes.statusCode).toBe(302); + + const completeRes = await app.inject({ + method: "GET", + url: String(startRes.headers.location ?? ""), + headers: { + "x-happier-client-cert-email": "alice@example.com", + "x-happier-client-cert-issuer": "CN=Example Root CA", + }, + }); + expect(completeRes.statusCode).toBe(302); + + const returnUrl = new URL(String(completeRes.headers.location ?? "")); + const code = returnUrl.searchParams.get("code"); + expect(code).toBeTruthy(); + + const [c1, c2] = await Promise.all([ + app.inject({ method: "POST", url: "/v1/auth/mtls/claim", payload: { code } }), + app.inject({ method: "POST", url: "/v1/auth/mtls/claim", payload: { code } }), + ]); + + const statuses = [c1.statusCode, c2.statusCode].sort(); + expect(statuses).toEqual([200, 401]); + + await app.close(); + }); + + it("rejects returnTo values that only match by string prefix but do not match the allowed origin", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "plain", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_FINGERPRINT_HEADER: "x-happier-client-cert-sha256", + + // A common operator config: "only allow returnTo into the webapp origin". + HAPPIER_FEATURE_AUTH_MTLS__RETURN_TO_ALLOW_PREFIXES: "https://app.happier.dev", + }); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "GET", + url: + "/v1/auth/mtls/start?returnTo=" + + encodeURIComponent("https://app.happier.dev.evil.com/oauth/mtls"), + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid-returnTo" }); + + await app.close(); + }); + + it("does not register mTLS routes when server storagePolicy=required_e2ee", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "1", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "required_e2ee", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "e2ee", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_FINGERPRINT_HEADER: "x-happier-client-cert-sha256", + }); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/mtls", + headers: { + "x-happier-client-cert-email": "alice@example.com", + "x-happier-client-cert-sha256": "sha256:abc123", + }, + }); + + expect(res.statusCode).toBe(404); + + await app.close(); + }); + + it("rejects identities that do not match allowed email domains", async () => { + Object.assign(process.env, { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "plain", + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: "example.com", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_FINGERPRINT_HEADER: "x-happier-client-cert-sha256", + }); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/mtls", + headers: { + "x-happier-client-cert-email": "alice@evil.example", + "x-happier-client-cert-sha256": "sha256:abc123", + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ error: "not-eligible" }); + + await app.close(); + }); +}); diff --git a/apps/server/sources/app/api/routes/auth/authRoutes.policy.integration.spec.ts b/apps/server/sources/app/api/routes/auth/authRoutes.policy.integration.spec.ts index f65700a8d..6f5cf74d4 100644 --- a/apps/server/sources/app/api/routes/auth/authRoutes.policy.integration.spec.ts +++ b/apps/server/sources/app/api/routes/auth/authRoutes.policy.integration.spec.ts @@ -135,6 +135,57 @@ describe("authRoutes (auth policy) (integration)", () => { await app.close(); }); + it("returns 404 when key-challenge login is disabled", async () => { + process.env.HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED = "0"; + // With key-challenge disabled, the server must still have at least one other + // viable login method configured to avoid a hard lockout. + process.env.AUTH_SIGNUP_PROVIDERS = "github"; + process.env.GITHUB_CLIENT_ID = "id"; + process.env.GITHUB_CLIENT_SECRET = "secret"; + process.env.GITHUB_REDIRECT_URL = "https://example.com/oauth/github/callback"; + + const { body } = createAuthBody(); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth", + payload: body, + }); + + expect(res.statusCode).toBe(404); + + await app.close(); + }); + + it("fails fast when key-challenge login is disabled and no other login methods are available", async () => { + process.env.HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED = "0"; + process.env.AUTH_SIGNUP_PROVIDERS = ""; + process.env.AUTH_ANONYMOUS_SIGNUP_ENABLED = "0"; + + const app = createTestApp(); + expect(() => authRoutes(app as any)).toThrow(/no login methods/i); + await app.close(); + }); + + it("does not fail fast when key-challenge login is disabled and a keyless OAuth login method is configured", async () => { + process.env.HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED = "0"; + process.env.AUTH_SIGNUP_PROVIDERS = ""; + process.env.AUTH_ANONYMOUS_SIGNUP_ENABLED = "0"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS = "github"; + process.env.GITHUB_CLIENT_ID = "id"; + process.env.GITHUB_CLIENT_SECRET = "secret"; + process.env.GITHUB_REDIRECT_URL = "https://example.com/oauth/github/callback"; + + const app = createTestApp(); + expect(() => authRoutes(app as any)).not.toThrow(); + await app.close(); + }); + it("returns 403 provider-required when a required identity provider is missing", async () => { process.env.AUTH_REQUIRED_LOGIN_PROVIDERS = "github"; @@ -191,6 +242,33 @@ describe("authRoutes (auth policy) (integration)", () => { await app.close(); }); + it("creates new accounts with encryptionMode=plain when plaintext storage is optional and defaultAccountMode=plain", async () => { + process.env.AUTH_ANONYMOUS_SIGNUP_ENABLED = "1"; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + process.env.HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE = "plain"; + + const { body, publicKeyHex } = createAuthBody(); + + const app = createTestApp(); + authRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth", + payload: body, + }); + expect(res.statusCode).toBe(200); + + const stored = await db.account.findUnique({ + where: { publicKey: publicKeyHex }, + select: { encryptionMode: true }, + }); + expect(stored?.encryptionMode).toBe("plain"); + + await app.close(); + }); + it("returns 403 not-eligible when GitHub is required and the GitHub allowlist does not include the user", async () => { process.env.AUTH_REQUIRED_LOGIN_PROVIDERS = "github"; process.env.AUTH_GITHUB_ALLOWED_USERS = "bob"; diff --git a/apps/server/sources/app/api/routes/auth/authRoutes.ts b/apps/server/sources/app/api/routes/auth/authRoutes.ts index 7cfeebcdf..5f9fab0a5 100644 --- a/apps/server/sources/app/api/routes/auth/authRoutes.ts +++ b/apps/server/sources/app/api/routes/auth/authRoutes.ts @@ -1,8 +1,21 @@ import { type Fastify } from "../../types"; -import { registerKeyChallengeAuthRoute } from "./registerKeyChallengeAuthRoute"; import { registerTerminalAuthRequestRoutes } from "./registerTerminalAuthRequestRoutes"; import { registerAccountAuthRoutes } from "./registerAccountAuthRoutes"; import { resolveTerminalAuthRequestPolicyFromEnv } from "./terminalAuthRequestPolicy"; +import { readAuthFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { resolveAuthFeature } from "@/app/features/authFeature"; +import { resolveAuthMethodRegistry } from "@/app/auth/methods/registry"; + +function hasAnyViableNonKeyChallengeAuthMethod(env: NodeJS.ProcessEnv): boolean { + const feature = resolveAuthFeature(env); + const methods = feature.capabilities?.auth?.methods ?? []; + return methods.some((m: any) => { + const id = String(m?.id ?? "").trim().toLowerCase(); + if (!id || id === "key_challenge") return false; + const actions = Array.isArray(m?.actions) ? m.actions : []; + return actions.some((a: any) => a?.enabled === true && (a?.id === "login" || a?.id === "provision")); + }); +} export function authRoutes(app: Fastify): void { const terminalAuthPolicy = resolveTerminalAuthRequestPolicyFromEnv(process.env); @@ -11,7 +24,18 @@ export function authRoutes(app: Fastify): void { return ageMs > terminalAuthPolicy.ttlMs; }; - registerKeyChallengeAuthRoute(app); + const authFeatureEnv = readAuthFeatureEnv(process.env); + if (!authFeatureEnv.loginKeyChallengeEnabled) { + if (!hasAnyViableNonKeyChallengeAuthMethod(process.env)) { + throw new Error( + "No login methods are available: HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED=0, no viable AUTH_SIGNUP_PROVIDERS are configured, and no other login providers are enabled.", + ); + } + } + const authMethodRegistry = resolveAuthMethodRegistry(process.env); + for (const method of authMethodRegistry) { + method.registerRoutes(app); + } registerTerminalAuthRequestRoutes(app, { terminalAuthPolicy, isTerminalAuthExpired }); registerAccountAuthRoutes(app); } diff --git a/apps/server/sources/app/api/routes/auth/registerKeyChallengeAuthRoute.ts b/apps/server/sources/app/api/routes/auth/registerKeyChallengeAuthRoute.ts index 7dc5b8e4a..6d5589e25 100644 --- a/apps/server/sources/app/api/routes/auth/registerKeyChallengeAuthRoute.ts +++ b/apps/server/sources/app/api/routes/auth/registerKeyChallengeAuthRoute.ts @@ -5,6 +5,8 @@ import { auth } from "@/app/auth/auth"; import { resolveAuthPolicyFromEnv } from "@/app/auth/authPolicy"; import { enforceLoginEligibility } from "@/app/auth/enforceLoginEligibility"; import { type Fastify } from "../../types"; +import { readEncryptionFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { resolveEffectiveDefaultAccountEncryptionMode } from "@happier-dev/protocol"; export function registerKeyChallengeAuthRoute(app: Fastify): void { app.post('/v1/auth', { @@ -94,6 +96,12 @@ export function registerKeyChallengeAuthRoute(app: Fastify): void { // Create or update user in database const publicKeyHex = privacyKit.encodeHex(publicKey); + const encryptionFeatureEnv = readEncryptionFeatureEnv(process.env); + const effectiveDefaultEncryptionMode = resolveEffectiveDefaultAccountEncryptionMode( + encryptionFeatureEnv.storagePolicy, + encryptionFeatureEnv.defaultAccountMode, + ); + const existingAccount = await db.account.findUnique({ where: { publicKey: publicKeyHex }, select: { @@ -126,6 +134,7 @@ export function registerKeyChallengeAuthRoute(app: Fastify): void { }, create: { publicKey: publicKeyHex, + encryptionMode: effectiveDefaultEncryptionMode, ...(contentPublicKey ? { contentPublicKey: new Uint8Array(contentPublicKey) } : {}), ...(contentPublicKeySig ? { contentPublicKeySig: new Uint8Array(contentPublicKeySig) } : {}), } diff --git a/apps/server/sources/app/api/routes/changes/changesRoutes.rateLimit.spec.ts b/apps/server/sources/app/api/routes/changes/changesRoutes.rateLimit.spec.ts new file mode 100644 index 000000000..31dbb0054 --- /dev/null +++ b/apps/server/sources/app/api/routes/changes/changesRoutes.rateLimit.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; + +class FakeApp { + public authenticate = vi.fn(); + public routes = new Map<string, any>(); + + get(path: string, opts: any, handler: any) { + this.routes.set(`GET ${path}`, { opts, handler }); + } + post() {} +} + +describe("changesRoutes rate limits", () => { + it("registers GET /v2/changes with an explicit rate limit", async () => { + const { changesRoutes } = await import("./changesRoutes"); + const app = new FakeApp(); + changesRoutes(app as any); + const cursorRoute = app.routes.get("GET /v2/cursor"); + expect(cursorRoute?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + const route = app.routes.get("GET /v2/changes"); + expect(route?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); diff --git a/apps/server/sources/app/api/routes/changes/changesRoutes.ts b/apps/server/sources/app/api/routes/changes/changesRoutes.ts index 5b9c197aa..f173211c4 100644 --- a/apps/server/sources/app/api/routes/changes/changesRoutes.ts +++ b/apps/server/sources/app/api/routes/changes/changesRoutes.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { type Fastify } from "../../types"; import { changesRequestsCounter, changesReturnedChangesCounter } from "@/app/monitoring/metrics2"; import { debug, warn } from "@/utils/logging/log"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; function redactIdForLogs(id: string): string { if (id.length <= 8) return `${id.slice(0, 2)}…`; @@ -21,6 +22,14 @@ export function changesRoutes(app: Fastify) { 404: z.object({ error: z.literal('account-not-found') }), }, }, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_CHANGES_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_CHANGES_RATE_LIMIT_WINDOW", + defaultMax: 600, + defaultWindow: "1 minute", + }), + }, }, async (request, reply) => { const userId = request.userId; const account = await db.account.findUnique({ @@ -43,6 +52,14 @@ export function changesRoutes(app: Fastify) { limit: z.coerce.number().int().min(1).max(500).default(200), }).optional(), }, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_CHANGES_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_CHANGES_RATE_LIMIT_WINDOW", + defaultMax: 600, + defaultWindow: "1 minute", + }), + }, }, async (request, reply) => { const userId = request.userId; const userIdRedacted = redactIdForLogs(userId); diff --git a/apps/server/sources/app/api/routes/connect/connectRoutes.authExternal.ts b/apps/server/sources/app/api/routes/connect/connectRoutes.authExternal.ts index a7113c8d4..a43802715 100644 --- a/apps/server/sources/app/api/routes/connect/connectRoutes.authExternal.ts +++ b/apps/server/sources/app/api/routes/connect/connectRoutes.authExternal.ts @@ -10,9 +10,12 @@ import { createExternalAuthorizeUrl } from "./oauthExternal/createExternalAuthor import { oauthExternalRateLimitPerIp } from "./oauthExternal/oauthExternalRateLimits"; import { OAUTH_NOT_CONFIGURED_ERROR } from "./oauthExternal/oauthExternalErrors"; import { registerExternalAuthFinalizeRoute } from "./oauthExternal/registerExternalAuthFinalizeRoute"; +import { registerExternalAuthFinalizeKeylessRoute } from "./oauthExternal/registerExternalAuthFinalizeKeylessRoute"; import { authPendingSchema } from "./oauthExternal/oauthExternalSchemas"; import { deleteOAuthPendingBestEffort, loadValidOAuthPending } from "./connectRoutes.oauthPending"; import { ExternalOAuthErrorResponseSchema, ExternalOAuthParamsResponseSchema } from "@happier-dev/protocol"; +import { readAuthOauthKeylessFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { resolveKeylessAccountsAvailability } from "@/app/features/e2ee/resolveKeylessAccountsEnabled"; export function connectAuthExternalRoutes(app: Fastify) { // @@ -23,7 +26,15 @@ export function connectAuthExternalRoutes(app: Fastify) { config: { rateLimit: oauthExternalRateLimitPerIp() }, schema: { params: z.object({ provider: z.string() }), - querystring: z.object({ publicKey: z.string() }), + querystring: z + .object({ + publicKey: z.string().optional(), + mode: z.enum(["keyed", "keyless"]).optional(), + proofHash: z.string().optional(), + }) + .refine((q) => (q.mode === "keyless" ? Boolean(q.proofHash) : Boolean(q.publicKey)), { + message: "Expected publicKey (keyed) or proofHash (keyless)", + }), response: { 200: ExternalOAuthParamsResponseSchema, 400: ExternalOAuthErrorResponseSchema, @@ -36,20 +47,38 @@ export function connectAuthExternalRoutes(app: Fastify) { const provider = findOAuthProviderById(process.env, providerId); if (!provider) return reply.code(404).send({ error: "unsupported-provider" }); + const mode = (request.query as any)?.mode === "keyless" ? "keyless" : "keyed"; const policy = resolveAuthPolicyFromEnv(process.env); - if (!policy.signupProviders.includes(providerId)) { - return reply.code(403).send({ error: "signup-provider-disabled" }); + if (mode === "keyed") { + if (!policy.signupProviders.includes(providerId)) { + return reply.code(403).send({ error: "signup-provider-disabled" }); + } + } else { + const keyless = readAuthOauthKeylessFeatureEnv(process.env); + if (!(keyless.enabled && keyless.providers.includes(providerId))) { + return reply.code(403).send({ error: "keyless-disabled" }); + } + const availability = resolveKeylessAccountsAvailability(process.env); + if (!availability.ok) { + return reply.code(403).send({ error: availability.reason === "e2ee-required" ? "e2ee-required" : "keyless-disabled" }); + } } - let publicKeyHex: string; - try { - const publicKeyBytes = privacyKit.decodeBase64(request.query.publicKey); - if (publicKeyBytes.length !== tweetnacl.sign.publicKeyLength) { + let publicKeyHex: string | null = null; + let proofHash: string | null = null; + if (mode === "keyed") { + try { + const publicKeyBytes = privacyKit.decodeBase64((request.query as any).publicKey); + if (publicKeyBytes.length !== tweetnacl.sign.publicKeyLength) { + return reply.code(400).send({ error: "Invalid public key" }); + } + publicKeyHex = privacyKit.encodeHex(publicKeyBytes); + } catch { return reply.code(400).send({ error: "Invalid public key" }); } - publicKeyHex = privacyKit.encodeHex(publicKeyBytes); - } catch { - return reply.code(400).send({ error: "Invalid public key" }); + } else { + proofHash = String((request.query as any)?.proofHash ?? "").trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(proofHash)) return reply.code(400).send({ error: "Invalid proof" }); } try { @@ -59,6 +88,7 @@ export function connectAuthExternalRoutes(app: Fastify) { providerId, provider, publicKeyHex, + proofHash, }); if (!url) return reply.code(400).send({ error: OAUTH_STATE_UNAVAILABLE_CODE }); return reply.send({ url }); @@ -71,6 +101,7 @@ export function connectAuthExternalRoutes(app: Fastify) { }); registerExternalAuthFinalizeRoute(app); + registerExternalAuthFinalizeKeylessRoute(app); app.delete("/v1/auth/external/:provider/pending/:pending", { schema: { @@ -105,4 +136,3 @@ export function connectAuthExternalRoutes(app: Fastify) { return reply.send({ success: true }); }); } - diff --git a/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthCallback.feat.connectedServices.integration.spec.ts b/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthCallback.feat.connectedServices.integration.spec.ts index ff0a9f208..15b726601 100644 --- a/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthCallback.feat.connectedServices.integration.spec.ts +++ b/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthCallback.feat.connectedServices.integration.spec.ts @@ -532,4 +532,87 @@ describe("connectRoutes (GitHub callback) external auth flow (integration)", () await app.close(); }); + + it("does not prompt for username when the GitHub identity is already linked (auth signup flows should restore instead)", async () => { + process.env.GITHUB_CLIENT_ID = "gh_client"; + process.env.GITHUB_CLIENT_SECRET = "gh_secret"; + process.env.GITHUB_REDIRECT_URL = "https://api.example.test/v1/oauth/github/callback"; + process.env.AUTH_SIGNUP_PROVIDERS = "github"; + process.env.HAPPIER_WEBAPP_URL = "https://app.example.test"; + + const existing = await db.account.create({ + data: { + publicKey: "pk_existing_1", + username: "octocat", + }, + select: { id: true }, + }); + await db.accountIdentity.create({ + data: { + accountId: existing.id, + provider: "github", + providerUserId: "123", + providerLogin: "octocat", + showOnProfile: true, + }, + }); + + const seed = new Uint8Array(32).fill(3); + const kp = tweetnacl.sign.keyPair.fromSeed(seed); + const publicKey = privacyKit.encodeBase64(new Uint8Array(kp.publicKey)); + + const ghProfile = { + id: 123, + login: "octocat", + avatar_url: "https://avatars.example.test/octo.png", + name: "Octo Cat", + }; + + const fetchMock = vi.fn(async (url: any) => { + if (typeof url === "string" && url.includes("https://github.com/login/oauth/access_token")) { + return { ok: true, json: async () => ({ access_token: "tok_1" }) } as any; + } + if (typeof url === "string" && url.includes("https://api.github.com/user")) { + return { ok: true, json: async () => ghProfile } as any; + } + throw new Error(`Unexpected fetch: ${String(url)}`); + }); + vi.stubGlobal("fetch", fetchMock as any); + + const app = createTestApp(); + connectRoutes(app as any); + await app.ready(); + + const paramsRes = await app.inject({ + method: "GET", + url: `/v1/auth/external/github/params?publicKey=${encodeURIComponent(publicKey)}`, + }); + expect(paramsRes.statusCode).toBe(200); + const paramsUrl = new URL((paramsRes.json() as { url: string }).url); + const state = paramsUrl.searchParams.get("state"); + expect(state).toBeTruthy(); + + const res = await app.inject({ + method: "GET", + url: `/v1/oauth/github/callback?code=c1&state=${encodeURIComponent(state!)}`, + }); + + expect(res.statusCode).toBe(302); + const redirect = new URL(res.headers.location as string); + expect(redirect.origin + redirect.pathname).toBe("https://app.example.test/oauth/github"); + expect(redirect.searchParams.get("flow")).toBe("auth"); + expect(redirect.searchParams.get("status")).toBeNull(); + const pending = redirect.searchParams.get("pending"); + expect(pending).toBeTruthy(); + + const pendingRow = await db.repeatKey.findUnique({ where: { key: pending as string } }); + expect(pendingRow).toBeTruthy(); + const pendingJson = JSON.parse(pendingRow!.value) as any; + expect(pendingJson.usernameRequired).toBe(false); + + const accounts = await db.account.findMany(); + expect(accounts.length).toBe(1); + + await app.close(); + }); }); diff --git a/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthFinalize.keyless.integration.spec.ts b/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthFinalize.keyless.integration.spec.ts new file mode 100644 index 000000000..ba3bc48bb --- /dev/null +++ b/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthFinalize.keyless.integration.spec.ts @@ -0,0 +1,329 @@ +import Fastify from "fastify"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod"; +import * as privacyKit from "privacy-kit"; + +import { initDbSqlite, db } from "@/storage/db"; +import { applyLightDefaultEnv, ensureHandyMasterSecret } from "@/flavors/light/env"; +import { connectRoutes } from "./connectRoutes"; +import { auth } from "@/app/auth/auth"; +import { initEncrypt } from "@/modules/encrypt"; +import { encryptString } from "@/modules/encrypt"; +import { initFilesLocalFromEnv, loadFiles } from "@/storage/blob/files"; +import { createAppCloseTracker } from "../../testkit/appLifecycle"; + +const { trackApp, closeTrackedApps } = createAppCloseTracker(); + +function runServerPrismaMigrateDeploySqlite(params: { cwd: string; env: NodeJS.ProcessEnv }): void { + const res = spawnSync( + "yarn", + ["-s", "prisma", "migrate", "deploy", "--schema", "prisma/sqlite/schema.prisma"], + { + cwd: params.cwd, + env: { ...(params.env as Record<string, string>), RUST_LOG: "info" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (res.status !== 0) { + const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`.trim(); + throw new Error(`prisma migrate deploy failed (status=${res.status}). ${out}`); + } +} + +function createTestApp() { + const app = Fastify({ logger: false }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + const typed = app.withTypeProvider<ZodTypeProvider>() as any; + return trackApp(typed); +} + +describe("connectRoutes (external auth finalize keyless) (integration)", () => { + const envBackup = { ...process.env }; + let testEnvBase: NodeJS.ProcessEnv; + let baseDir: string; + + beforeAll(async () => { + baseDir = await mkdtemp(join(tmpdir(), "happier-auth-external-finalize-keyless-")); + const dbPath = join(baseDir, "test.sqlite"); + + process.env = { + ...process.env, + HAPPIER_DB_PROVIDER: "sqlite", + HAPPY_DB_PROVIDER: "sqlite", + DATABASE_URL: `file:${dbPath}`, + HAPPY_SERVER_LIGHT_DATA_DIR: baseDir, + HAPPIER_SERVER_LIGHT_DATA_DIR: baseDir, + }; + applyLightDefaultEnv(process.env); + await ensureHandyMasterSecret(process.env); + testEnvBase = { ...process.env }; + + runServerPrismaMigrateDeploySqlite({ cwd: process.cwd(), env: process.env }); + await initDbSqlite(); + await db.$connect(); + await auth.init(); + await initEncrypt(); + initFilesLocalFromEnv(process.env); + await loadFiles(); + }, 120_000); + + const restoreEnv = (base: NodeJS.ProcessEnv) => { + for (const key of Object.keys(process.env)) { + if (!(key in base)) { + delete (process.env as any)[key]; + } + } + for (const [key, value] of Object.entries(base)) { + if (typeof value === "string") { + process.env[key] = value; + } + } + }; + + afterEach(async () => { + await closeTrackedApps(); + restoreEnv(testEnvBase); + await db.userFeedItem.deleteMany(); + await db.userRelationship.deleteMany(); + await db.repeatKey.deleteMany(); + await db.uploadedFile.deleteMany(); + await db.accountIdentity.deleteMany(); + await db.account.deleteMany(); + }); + + afterAll(async () => { + await db.$disconnect(); + process.env = envBackup; + await rm(baseDir, { recursive: true, force: true }); + }); + + it("POST /v1/auth/external/:provider/finalize-keyless provisions a keyless account and returns a token when enabled", async () => { + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS = "github"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_AUTO_PROVISION = "1"; + process.env.HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + process.env.HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE = "plain"; + + const pendingKey = "oauth_pending_keylessA1"; + const proof = "proof_secret_1"; + const proofHash = createHash("sha256").update(proof, "utf8").digest("hex"); + + const githubProfile = { + id: 123, + login: "octocat", + avatar_url: "", + name: "The Octocat", + }; + + await db.repeatKey.create({ + data: { + key: pendingKey, + value: JSON.stringify({ + flow: "auth", + provider: "github", + authMode: "keyless", + proofHash, + profileEnc: privacyKit.encodeBase64( + encryptString(["auth", "external", "github", "pending_keyless", pendingKey, "profile"], JSON.stringify(githubProfile)), + ), + accessTokenEnc: privacyKit.encodeBase64( + encryptString(["auth", "external", "github", "pending_keyless", pendingKey, "token"], "tok_1"), + ), + suggestedUsername: "octocat", + usernameRequired: false, + usernameReason: null, + }), + expiresAt: new Date(Date.now() + 60_000), + }, + }); + + const app = createTestApp(); + connectRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/external/github/finalize-keyless", + headers: { "content-type": "application/json" }, + payload: { pending: pendingKey, proof }, + }); + + expect(res.statusCode).toBe(200); + const json = res.json(); + expect(json).toMatchObject({ success: true }); + expect(typeof json.token).toBe("string"); + + const accounts = await db.account.findMany({ select: { id: true, publicKey: true, encryptionMode: true } }); + expect(accounts.length).toBe(1); + expect(accounts[0].publicKey).toBeNull(); + expect(accounts[0].encryptionMode).toBe("plain"); + + const identities = await db.accountIdentity.findMany({ + where: { provider: "github", providerUserId: "123" }, + select: { accountId: true }, + }); + expect(identities.length).toBe(1); + expect(identities[0].accountId).toBe(accounts[0].id); + + const pending = await db.repeatKey.findUnique({ where: { key: pendingKey } }); + expect(pending).toBeNull(); + + await app.close(); + }); + + it("POST /v1/auth/external/:provider/finalize-keyless returns 409 restore-required when the external identity is linked to a keyed account", async () => { + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS = "github"; + process.env.HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + + const keyedAccount = await db.account.create({ + data: { publicKey: "pk_hex_1", encryptionMode: "e2ee" }, + select: { id: true }, + }); + await db.accountIdentity.create({ + data: { + accountId: keyedAccount.id, + provider: "github", + providerUserId: "123", + providerLogin: "octocat", + profile: { id: 123, login: "octocat" }, + showOnProfile: false, + }, + }); + + const pendingKey = "oauth_pending_keylessB1"; + const proof = "proof_secret_2"; + const proofHash = createHash("sha256").update(proof, "utf8").digest("hex"); + + const githubProfile = { + id: 123, + login: "octocat", + avatar_url: "", + name: "The Octocat", + }; + + await db.repeatKey.create({ + data: { + key: pendingKey, + value: JSON.stringify({ + flow: "auth", + provider: "github", + authMode: "keyless", + proofHash, + profileEnc: privacyKit.encodeBase64( + encryptString(["auth", "external", "github", "pending_keyless", pendingKey, "profile"], JSON.stringify(githubProfile)), + ), + accessTokenEnc: privacyKit.encodeBase64( + encryptString(["auth", "external", "github", "pending_keyless", pendingKey, "token"], "tok_2"), + ), + suggestedUsername: "octocat", + usernameRequired: false, + usernameReason: null, + }), + expiresAt: new Date(Date.now() + 60_000), + }, + }); + + const app = createTestApp(); + connectRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/external/github/finalize-keyless", + headers: { "content-type": "application/json" }, + payload: { pending: pendingKey, proof }, + }); + + expect(res.statusCode).toBe(409); + expect(res.json()).toEqual({ error: "restore-required" }); + + const pending = await db.repeatKey.findUnique({ where: { key: pendingKey } }); + expect(pending).toBeNull(); + + await app.close(); + }); + + it("POST /v1/auth/external/:provider/finalize-keyless returns 403 e2ee-required when server storagePolicy=required_e2ee", async () => { + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS = "github"; + process.env.HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "required_e2ee"; + + const keylessAccount = await db.account.create({ + data: { publicKey: null, encryptionMode: "plain" }, + select: { id: true }, + }); + await db.accountIdentity.create({ + data: { + accountId: keylessAccount.id, + provider: "github", + providerUserId: "123", + providerLogin: "octocat", + profile: { id: 123, login: "octocat" }, + showOnProfile: false, + }, + }); + + const pendingKey = "oauth_pending_keylessC1"; + const proof = "proof_secret_3"; + const proofHash = createHash("sha256").update(proof, "utf8").digest("hex"); + + const githubProfile = { + id: 123, + login: "octocat", + avatar_url: "", + name: "The Octocat", + }; + + await db.repeatKey.create({ + data: { + key: pendingKey, + value: JSON.stringify({ + flow: "auth", + provider: "github", + authMode: "keyless", + proofHash, + profileEnc: privacyKit.encodeBase64( + encryptString(["auth", "external", "github", "pending_keyless", pendingKey, "profile"], JSON.stringify(githubProfile)), + ), + accessTokenEnc: privacyKit.encodeBase64( + encryptString(["auth", "external", "github", "pending_keyless", pendingKey, "token"], "tok_3"), + ), + suggestedUsername: "octocat", + usernameRequired: false, + usernameReason: null, + }), + expiresAt: new Date(Date.now() + 60_000), + }, + }); + + const app = createTestApp(); + connectRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/external/github/finalize-keyless", + headers: { "content-type": "application/json" }, + payload: { pending: pendingKey, proof }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ error: "e2ee-required" }); + + const pending = await db.repeatKey.findUnique({ where: { key: pendingKey } }); + expect(pending).toBeNull(); + + await app.close(); + }); +}); diff --git a/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthParams.feat.connectedServices.integration.spec.ts b/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthParams.feat.connectedServices.integration.spec.ts index 1ed19269b..6ab4dc66c 100644 --- a/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthParams.feat.connectedServices.integration.spec.ts +++ b/apps/server/sources/app/api/routes/connect/connectRoutes.externalAuthParams.feat.connectedServices.integration.spec.ts @@ -170,4 +170,53 @@ describe("connectRoutes (external auth params)", () => { await app.close(); }); + + it("rejects keyless auth params when server storagePolicy=required_e2ee", async () => { + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS = "github"; + process.env.HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "required_e2ee"; + + process.env.GITHUB_CLIENT_ID = "gh_client"; + process.env.GITHUB_REDIRECT_URL = "https://api.example.test/v1/oauth/github/callback"; + + const app = createTestApp(); + connectRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "GET", + url: `/v1/auth/external/github/params?mode=keyless&proofHash=${encodeURIComponent("a".repeat(64))}`, + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ error: "e2ee-required" }); + + await app.close(); + }); + + it("rejects keyless auth params when proofHash is not a sha256 hex string", async () => { + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS = "github"; + process.env.HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + process.env.HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE = "plain"; + + process.env.GITHUB_CLIENT_ID = "gh_client"; + process.env.GITHUB_REDIRECT_URL = "https://api.example.test/v1/oauth/github/callback"; + + const app = createTestApp(); + connectRoutes(app as any); + await app.ready(); + + const res = await app.inject({ + method: "GET", + url: `/v1/auth/external/github/params?mode=keyless&proofHash=${encodeURIComponent("not-hex")}`, + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "Invalid proof" }); + + await app.close(); + }); }); diff --git a/apps/server/sources/app/api/routes/connect/connectRoutes.githubCallback.oauthStateAuthFlow.feat.connectedServices.integration.spec.ts b/apps/server/sources/app/api/routes/connect/connectRoutes.githubCallback.oauthStateAuthFlow.feat.connectedServices.integration.spec.ts index 8df6cd093..611a347d3 100644 --- a/apps/server/sources/app/api/routes/connect/connectRoutes.githubCallback.oauthStateAuthFlow.feat.connectedServices.integration.spec.ts +++ b/apps/server/sources/app/api/routes/connect/connectRoutes.githubCallback.oauthStateAuthFlow.feat.connectedServices.integration.spec.ts @@ -132,4 +132,92 @@ describe("connectRoutes (GitHub callback) oauth-state auth flow", () => { await app.close(); }); + + it("redirects with flow=auth&mode=keyless when the oauth state token indicates a keyless auth flow", async () => { + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS = "github"; + process.env.HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + process.env.GITHUB_CLIENT_ID = "gh_client"; + process.env.GITHUB_CLIENT_SECRET = "gh_secret"; + process.env.GITHUB_REDIRECT_URL = "https://api.example.test/v1/oauth/github/callback"; + process.env.HAPPIER_WEBAPP_URL = "https://app.example.test"; + + globalThis.fetch = (async (url: any) => { + if (typeof url === "string" && url.includes("https://github.com/login/oauth/access_token")) { + return { ok: true, json: async () => ({}) } as any; // missing access_token + } + throw new Error(`Unexpected fetch: ${String(url)}`); + }) as any; + + const proofHash = "a".repeat(64); + + const app = createTestApp(); + connectRoutes(app as any); + await app.ready(); + + const paramsRes = await app.inject({ + method: "GET", + url: `/v1/auth/external/github/params?mode=keyless&proofHash=${encodeURIComponent(proofHash)}`, + }); + expect(paramsRes.statusCode).toBe(200); + const paramsUrl = new URL((paramsRes.json() as { url: string }).url); + const state = paramsUrl.searchParams.get("state"); + expect(state).toBeTruthy(); + + const res = await app.inject({ + method: "GET", + url: `/v1/oauth/github/callback?code=c1&state=${encodeURIComponent(state!)}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe("https://app.example.test/oauth/github?flow=auth&mode=keyless&error=missing_access_token"); + + await app.close(); + }); + + it("redirects with error=e2ee_required when keyless auth becomes unavailable before the callback is handled", async () => { + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS = "github"; + process.env.HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED = "1"; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + process.env.GITHUB_CLIENT_ID = "gh_client"; + process.env.GITHUB_CLIENT_SECRET = "gh_secret"; + process.env.GITHUB_REDIRECT_URL = "https://api.example.test/v1/oauth/github/callback"; + process.env.HAPPIER_WEBAPP_URL = "https://app.example.test"; + + globalThis.fetch = (async (url: any) => { + if (typeof url === "string" && url.includes("https://github.com/login/oauth/access_token")) { + return { ok: true, json: async () => ({}) } as any; // missing access_token + } + throw new Error(`Unexpected fetch: ${String(url)}`); + }) as any; + + const proofHash = "b".repeat(64); + + const app = createTestApp(); + connectRoutes(app as any); + await app.ready(); + + const paramsRes = await app.inject({ + method: "GET", + url: `/v1/auth/external/github/params?mode=keyless&proofHash=${encodeURIComponent(proofHash)}`, + }); + expect(paramsRes.statusCode).toBe(200); + const paramsUrl = new URL((paramsRes.json() as { url: string }).url); + const state = paramsUrl.searchParams.get("state"); + expect(state).toBeTruthy(); + + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "required_e2ee"; + + const res = await app.inject({ + method: "GET", + url: `/v1/oauth/github/callback?code=c1&state=${encodeURIComponent(state!)}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe("https://app.example.test/oauth/github?flow=auth&mode=keyless&error=e2ee_required"); + + await app.close(); + }); }); diff --git a/apps/server/sources/app/api/routes/connect/connectRoutes.oauthExternal.rateLimit.feat.connectedServices.integration.spec.ts b/apps/server/sources/app/api/routes/connect/connectRoutes.oauthExternal.rateLimit.feat.connectedServices.spec.ts similarity index 83% rename from apps/server/sources/app/api/routes/connect/connectRoutes.oauthExternal.rateLimit.feat.connectedServices.integration.spec.ts rename to apps/server/sources/app/api/routes/connect/connectRoutes.oauthExternal.rateLimit.feat.connectedServices.spec.ts index 6d6ec2f05..a9a8fc622 100644 --- a/apps/server/sources/app/api/routes/connect/connectRoutes.oauthExternal.rateLimit.feat.connectedServices.integration.spec.ts +++ b/apps/server/sources/app/api/routes/connect/connectRoutes.oauthExternal.rateLimit.feat.connectedServices.spec.ts @@ -27,9 +27,12 @@ describe("connectRoutes (oauth external) rate limit", () => { const connectParams = app.getOptsByPath.get("/v1/connect/external/:provider/params"); expect(connectParams?.config?.rateLimit).toEqual(expect.objectContaining({ max: expect.any(Number) })); + expect(connectParams?.config?.rateLimit?.keyGenerator).toEqual(expect.any(Function)); + expect(connectParams?.config?.rateLimit?.keyGenerator?.({ headers: { authorization: "Bearer token_1" }, ip: "203.0.113.9" })).toMatch( + /^auth:/, + ); const callback = app.getOptsByPath.get("/v1/oauth/:provider/callback"); expect(callback?.config?.rateLimit).toEqual(expect.objectContaining({ max: expect.any(Number) })); }); }); - diff --git a/apps/server/sources/app/api/routes/connect/oauthExternal/createExternalAuthorizeUrl.ts b/apps/server/sources/app/api/routes/connect/oauthExternal/createExternalAuthorizeUrl.ts index 4853670e8..1328dfdd6 100644 --- a/apps/server/sources/app/api/routes/connect/oauthExternal/createExternalAuthorizeUrl.ts +++ b/apps/server/sources/app/api/routes/connect/oauthExternal/createExternalAuthorizeUrl.ts @@ -14,7 +14,8 @@ type ExternalAuthorizeFlowParams = providerId: string; provider: OAuthFlowProvider; env: NodeJS.ProcessEnv; - publicKeyHex: string; + publicKeyHex: string | null; + proofHash: string | null; }> | Readonly<{ flow: "connect"; @@ -57,6 +58,7 @@ export async function createExternalAuthorizeUrl(params: ExternalAuthorizeFlowPa provider: params.providerId, sid, publicKey: params.publicKeyHex, + proofHash: params.proofHash, }) : await auth.createOauthStateToken({ flow: "connect", diff --git a/apps/server/sources/app/api/routes/connect/oauthExternal/oauthExternalRateLimits.ts b/apps/server/sources/app/api/routes/connect/oauthExternal/oauthExternalRateLimits.ts index fb6231a06..27c975dae 100644 --- a/apps/server/sources/app/api/routes/connect/oauthExternal/oauthExternalRateLimits.ts +++ b/apps/server/sources/app/api/routes/connect/oauthExternal/oauthExternalRateLimits.ts @@ -1,14 +1,16 @@ +import { createApiRateLimitKeyGenerator, gateRateLimitConfig } from "@/app/api/utils/apiRateLimitPolicy"; + export function oauthExternalRateLimitPerIp() { - return { + return gateRateLimitConfig(process.env, { max: 60, timeWindow: "1 minute", - }; + }); } export function oauthExternalRateLimitPerUser() { - return { + return gateRateLimitConfig(process.env, { max: 60, timeWindow: "1 minute", - keyGenerator: (request: any) => request?.userId?.toString?.() ?? request?.ip ?? "unknown", - }; + keyGenerator: createApiRateLimitKeyGenerator(), + }); } diff --git a/apps/server/sources/app/api/routes/connect/oauthExternal/oauthExternalSchemas.ts b/apps/server/sources/app/api/routes/connect/oauthExternal/oauthExternalSchemas.ts index 5cc607981..86d0803c0 100644 --- a/apps/server/sources/app/api/routes/connect/oauthExternal/oauthExternalSchemas.ts +++ b/apps/server/sources/app/api/routes/connect/oauthExternal/oauthExternalSchemas.ts @@ -18,7 +18,9 @@ export const connectPendingSchema = z.object({ export const authPendingSchema = z.object({ flow: z.literal("auth"), provider: z.string(), - publicKeyHex: z.string(), + authMode: z.enum(["keyed", "keyless"]).optional().default("keyed"), + publicKeyHex: z.string().nullable().optional(), + proofHash: z.string().nullable().optional(), profileEnc: z.string(), accessTokenEnc: z.string(), refreshTokenEnc: z.string().optional(), diff --git a/apps/server/sources/app/api/routes/connect/oauthExternal/registerExternalAuthFinalizeKeylessRoute.ts b/apps/server/sources/app/api/routes/connect/oauthExternal/registerExternalAuthFinalizeKeylessRoute.ts new file mode 100644 index 000000000..4895cd047 --- /dev/null +++ b/apps/server/sources/app/api/routes/connect/oauthExternal/registerExternalAuthFinalizeKeylessRoute.ts @@ -0,0 +1,198 @@ +import { createHash } from "node:crypto"; +import * as privacyKit from "privacy-kit"; +import { z } from "zod"; + +import { type Fastify } from "../../../types"; +import { connectExternalIdentity } from "@/app/auth/providers/identity"; +import { auth } from "@/app/auth/auth"; +import { Context } from "@/context"; +import { decryptString } from "@/modules/encrypt"; +import { findOAuthProviderById } from "@/app/oauth/providers/registry"; +import { db } from "@/storage/db"; +import { validateUsername } from "@/app/social/usernamePolicy"; +import { loadValidOAuthPending, deleteOAuthPendingBestEffort } from "../connectRoutes.oauthPending"; +import { authPendingSchema } from "./oauthExternalSchemas"; +import { readAuthOauthKeylessFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { resolveKeylessAutoProvisionEligibility } from "@/app/auth/keyless/resolveKeylessAutoProvisionEligibility"; +import { resolveKeylessAccountsAvailability } from "@/app/features/e2ee/resolveKeylessAccountsEnabled"; + +function sha256Hex(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +export function registerExternalAuthFinalizeKeylessRoute(app: Fastify) { + app.post("/v1/auth/external/:provider/finalize-keyless", { + schema: { + params: z.object({ provider: z.string() }), + body: z.object({ + pending: z.string().min(1), + proof: z.string().min(1), + username: z.string().min(1).optional(), + }), + response: { + 200: z.object({ success: z.literal(true), token: z.string().min(1) }), + 400: z.object({ error: z.enum(["invalid-pending", "invalid-proof", "username-required", "invalid-username"]) }), + 403: z.object({ error: z.enum(["keyless-disabled", "not-eligible", "e2ee-required"]) }), + 404: z.object({ error: z.literal("unsupported-provider") }), + 409: z.object({ error: z.enum(["restore-required", "username-taken"]) }), + }, + }, + }, async (request, reply) => { + const providerId = request.params.provider.toString().trim().toLowerCase(); + const provider = findOAuthProviderById(process.env, providerId); + if (!provider) return reply.code(404).send({ error: "unsupported-provider" }); + + const keylessEnv = readAuthOauthKeylessFeatureEnv(process.env); + const allowed = keylessEnv.enabled && keylessEnv.providers.includes(providerId); + if (!allowed) return reply.code(403).send({ error: "keyless-disabled" }); + + const pendingKey = request.body.pending.toString().trim(); + if (!pendingKey) return reply.code(400).send({ error: "invalid-pending" }); + + const availability = resolveKeylessAccountsAvailability(process.env); + if (!availability.ok) { + await deleteOAuthPendingBestEffort(pendingKey); + return reply + .code(403) + .send({ error: availability.reason === "e2ee-required" ? "e2ee-required" : "keyless-disabled" }); + } + + const pending = await loadValidOAuthPending(pendingKey); + if (!pending) return reply.code(400).send({ error: "invalid-pending" }); + + let parsedValue: z.infer<typeof authPendingSchema>; + try { + const parsed = authPendingSchema.safeParse(JSON.parse(pending.value)); + if (!parsed.success) { + await deleteOAuthPendingBestEffort(pendingKey); + return reply.code(400).send({ error: "invalid-pending" }); + } + parsedValue = parsed.data; + } catch { + await deleteOAuthPendingBestEffort(pendingKey); + return reply.code(400).send({ error: "invalid-pending" }); + } + + if (parsedValue.flow !== "auth" || parsedValue.provider.toString().trim().toLowerCase() !== providerId) { + return reply.code(400).send({ error: "invalid-pending" }); + } + if (parsedValue.authMode !== "keyless") { + return reply.code(400).send({ error: "invalid-pending" }); + } + + const proof = request.body.proof.toString(); + const proofHash = sha256Hex(proof); + if (!parsedValue.proofHash || proofHash !== parsedValue.proofHash) { + return reply.code(400).send({ error: "invalid-proof" }); + } + + let accessToken: string; + let refreshToken: string | undefined; + let pendingProfile: unknown; + try { + const tokenBytes = privacyKit.decodeBase64(parsedValue.accessTokenEnc); + accessToken = decryptString(["auth", "external", providerId, "pending_keyless", pendingKey, "token"], tokenBytes); + if (typeof parsedValue.refreshTokenEnc === "string" && parsedValue.refreshTokenEnc.trim()) { + const refreshBytes = privacyKit.decodeBase64(parsedValue.refreshTokenEnc); + refreshToken = decryptString(["auth", "external", providerId, "pending_keyless", pendingKey, "refresh"], refreshBytes); + } + + const profileBytes = privacyKit.decodeBase64(parsedValue.profileEnc); + const profileJson = decryptString( + ["auth", "external", providerId, "pending_keyless", pendingKey, "profile"], + profileBytes, + ); + pendingProfile = JSON.parse(profileJson); + } catch { + return reply.code(400).send({ error: "invalid-pending" }); + } + + const providerUserId = provider.getProviderUserId(pendingProfile); + if (!providerUserId) { + await deleteOAuthPendingBestEffort(pendingKey); + return reply.code(400).send({ error: "invalid-pending" }); + } + + const existingIdentity = await db.accountIdentity.findFirst({ + where: { provider: providerId, providerUserId }, + select: { accountId: true }, + }); + if (existingIdentity) { + const existingAccount = await db.account.findUnique({ + where: { id: existingIdentity.accountId }, + select: { publicKey: true }, + }); + if (existingAccount?.publicKey) { + await db.repeatKey.deleteMany({ where: { key: pendingKey } }); + return reply.code(409).send({ error: "restore-required" }); + } + await db.repeatKey.deleteMany({ where: { key: pendingKey } }); + const token = await auth.createToken(existingIdentity.accountId); + return reply.send({ success: true, token }); + } + + if (!keylessEnv.autoProvision) { + return reply.code(403).send({ error: "not-eligible" }); + } + + const eligibility = resolveKeylessAutoProvisionEligibility(process.env); + if (!eligibility.ok) { + return reply.code(403).send({ error: eligibility.error }); + } + + const usernameProvidedRaw = request.body.username?.toString().trim() || ""; + let desiredUsername: string | null = null; + if (usernameProvidedRaw) { + const validation = validateUsername(usernameProvidedRaw, process.env); + if (!validation.ok) return reply.code(400).send({ error: "invalid-username" }); + desiredUsername = validation.username; + } else { + const required = parsedValue.usernameRequired === true; + if (required) return reply.code(400).send({ error: "username-required" }); + const suggested = parsedValue.suggestedUsername?.toString().trim() || ""; + if (suggested) { + const validation = validateUsername(suggested, process.env); + if (validation.ok) desiredUsername = validation.username; + } + } + + if (desiredUsername) { + const taken = await db.account.findFirst({ where: { username: desiredUsername }, select: { id: true } }); + if (taken) { + return reply.code(409).send({ error: "username-taken" }); + } + } + + const account = await db.account.create({ + data: { + publicKey: null, + encryptionMode: eligibility.encryptionMode, + ...(desiredUsername ? { username: desiredUsername } : {}), + }, + select: { id: true }, + }); + + const ctx = Context.create(account.id); + try { + await connectExternalIdentity({ + providerId, + ctx, + profile: pendingProfile, + accessToken, + refreshToken, + preferredUsername: desiredUsername, + }); + await db.repeatKey.deleteMany({ where: { key: pendingKey } }); + } catch (error) { + await db.account.delete({ where: { id: account.id } }).catch(() => {}); + await db.repeatKey.deleteMany({ where: { key: pendingKey } }); + if (error instanceof Error && error.message === "not-eligible") { + return reply.code(403).send({ error: "not-eligible" }); + } + throw error; + } + + const token = await auth.createToken(account.id); + return reply.send({ success: true, token }); + }); +} diff --git a/apps/server/sources/app/api/routes/connect/oauthExternal/registerExternalAuthFinalizeRoute.ts b/apps/server/sources/app/api/routes/connect/oauthExternal/registerExternalAuthFinalizeRoute.ts index 50c5bcbe9..1512abbb2 100644 --- a/apps/server/sources/app/api/routes/connect/oauthExternal/registerExternalAuthFinalizeRoute.ts +++ b/apps/server/sources/app/api/routes/connect/oauthExternal/registerExternalAuthFinalizeRoute.ts @@ -117,6 +117,11 @@ export function registerExternalAuthFinalizeRoute(app: Fastify) { return reply.code(400).send({ error: "invalid-pending" }); } + if (parsedValue.authMode !== "keyed" || !parsedValue.publicKeyHex) { + await deleteOAuthPendingBestEffort(pendingKey); + return reply.code(400).send({ error: "invalid-pending" }); + } + if (parsedValue.provider.toString().trim().toLowerCase() !== providerId) { return reply.code(403).send({ error: "forbidden" }); } diff --git a/apps/server/sources/app/api/routes/connect/oauthExternal/registerOAuthCallbackRoute.ts b/apps/server/sources/app/api/routes/connect/oauthExternal/registerOAuthCallbackRoute.ts index 048a373f2..bcf3d8195 100644 --- a/apps/server/sources/app/api/routes/connect/oauthExternal/registerOAuthCallbackRoute.ts +++ b/apps/server/sources/app/api/routes/connect/oauthExternal/registerOAuthCallbackRoute.ts @@ -14,6 +14,8 @@ import { validateUsername } from "@/app/social/usernamePolicy"; import { deleteOAuthStateAttemptBestEffort, loadValidOAuthStateAttempt } from "../connectRoutes.oauthStateAttempt"; import { log } from "@/utils/logging/log"; import { isServerFeatureEnabledForRequest } from "@/app/features/catalog/serverFeatureGate"; +import { readAuthOauthKeylessFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { resolveKeylessAccountsAvailability } from "@/app/features/e2ee/resolveKeylessAccountsEnabled"; import { buildRedirectUrl, resolveOAuthPendingTtlMsFromEnv, @@ -32,6 +34,7 @@ export function registerOAuthCallbackRoute(app: Fastify) { .object({ state: z.string(), code: z.string().optional(), + iss: z.string().optional(), error: z.string().optional(), error_description: z.string().optional(), }) @@ -48,7 +51,7 @@ export function registerOAuthCallbackRoute(app: Fastify) { return reply.redirect(buildRedirectUrl(webAppUrl, { error: "unsupported-provider" })); } - const { code, state } = request.query; + const { code, state, iss } = request.query; const oauthError = (request.query as any)?.error?.toString?.().trim?.() || ""; const oauthState = await auth.verifyOauthStateToken(state); @@ -82,7 +85,23 @@ export function registerOAuthCallbackRoute(app: Fastify) { } const flow = oauthState.flow; - const redirectBaseParams: Record<string, string> = { flow }; + const authMode = flow === "auth" && oauthState.publicKey ? "keyed" : flow === "auth" ? "keyless" : null; + const redirectBaseParams: Record<string, string> = + flow === "auth" && authMode === "keyless" ? { flow, mode: "keyless" } : { flow }; + + if (flow === "auth" && authMode === "keyless") { + const keyless = readAuthOauthKeylessFeatureEnv(process.env); + if (!(keyless.enabled && keyless.providers.includes(providerId))) { + return reply.redirect(buildRedirectUrl(webAppUrl, { ...redirectBaseParams, error: "keyless_disabled" })); + } + const availability = resolveKeylessAccountsAvailability(process.env); + if (!availability.ok) { + return reply.redirect(buildRedirectUrl(webAppUrl, { + ...redirectBaseParams, + error: availability.reason === "e2ee-required" ? "e2ee_required" : "keyless_disabled", + })); + } + } if (flow === "connect" && !isServerFeatureEnabledForRequest("connectedServices", process.env)) { return reply.redirect(buildRedirectUrl(webAppUrl, { ...redirectBaseParams, error: "connect_disabled" })); @@ -94,10 +113,14 @@ export function registerOAuthCallbackRoute(app: Fastify) { const userId = flow === "connect" ? oauthState.userId : null; const publicKeyHex = flow === "auth" ? oauthState.publicKey : null; + const proofHash = flow === "auth" ? oauthState.proofHash : null; if (flow === "connect" && !userId) { return reply.redirect(buildRedirectUrl(webAppUrl, { ...redirectBaseParams, error: "invalid_state" })); } - if (flow === "auth" && !publicKeyHex) { + if (flow === "auth" && authMode === "keyed" && !publicKeyHex) { + return reply.redirect(buildRedirectUrl(webAppUrl, { ...redirectBaseParams, error: "invalid_state" })); + } + if (flow === "auth" && authMode === "keyless" && !proofHash) { return reply.redirect(buildRedirectUrl(webAppUrl, { ...redirectBaseParams, error: "invalid_state" })); } @@ -110,6 +133,7 @@ export function registerOAuthCallbackRoute(app: Fastify) { env: process.env, code, state, + iss, pkceCodeVerifier: attemptParsed.data.pkceCodeVerifier, expectedNonce: attemptParsed.data.nonce, }); @@ -117,6 +141,18 @@ export function registerOAuthCallbackRoute(app: Fastify) { const login = provider.getLogin(profile) ?? ""; if (flow === "auth") { + const providerUserId = provider.getProviderUserId(profile); + const alreadyLinked = providerUserId + ? await db.accountIdentity.findFirst({ + where: { + provider: providerId, + providerUserId, + }, + select: { id: true }, + }) + : null; + const isAlreadyLinked = Boolean(alreadyLinked); + const loginUsername = login ? login.toLowerCase() : null; let suggestedUsername: string | null = null; let usernameRequired = false; @@ -125,22 +161,28 @@ export function registerOAuthCallbackRoute(app: Fastify) { if (loginUsername) { const loginValidation = validateUsername(loginUsername, process.env); if (!loginValidation.ok) { - usernameRequired = true; - usernameReason = "invalid_login"; + if (!isAlreadyLinked) { + usernameRequired = true; + usernameReason = "invalid_login"; + } } else { suggestedUsername = loginValidation.username; - const taken = await db.account.findFirst({ - where: { username: suggestedUsername }, - select: { id: true }, - }); - if (taken) { - usernameRequired = true; - usernameReason = "login_taken"; + if (!isAlreadyLinked) { + const taken = await db.account.findFirst({ + where: { username: suggestedUsername }, + select: { id: true }, + }); + if (taken) { + usernameRequired = true; + usernameReason = "login_taken"; + } } } } else { - usernameRequired = true; - usernameReason = "invalid_login"; + if (!isAlreadyLinked) { + usernameRequired = true; + usernameReason = "invalid_login"; + } } const pendingKey = `oauth_pending_${randomKeyNaked(24)}`; @@ -150,6 +192,55 @@ export function registerOAuthCallbackRoute(app: Fastify) { } catch { return reply.redirect(buildRedirectUrl(webAppUrl, { ...redirectBaseParams, error: "invalid_profile" })); } + + if (authMode === "keyless") { + const tokenEnc = privacyKit.encodeBase64( + encryptString(["auth", "external", providerId, "pending_keyless", pendingKey, "token"], accessToken), + ); + const profileEnc = privacyKit.encodeBase64( + encryptString(["auth", "external", providerId, "pending_keyless", pendingKey, "profile"], profileJson), + ); + const refreshTokenEnc = + typeof refreshToken === "string" && refreshToken.trim() + ? privacyKit.encodeBase64( + encryptString( + ["auth", "external", providerId, "pending_keyless", pendingKey, "refresh"], + refreshToken, + ), + ) + : undefined; + const ttlMs = resolveOAuthPendingTtlMsFromEnv(process.env); + await db.repeatKey.create({ + data: { + key: pendingKey, + value: JSON.stringify({ + flow: "auth", + provider: providerId, + authMode: "keyless", + proofHash: proofHash!, + profileEnc, + accessTokenEnc: tokenEnc, + ...(refreshTokenEnc ? { refreshTokenEnc } : {}), + suggestedUsername, + usernameRequired, + usernameReason, + }), + expiresAt: new Date(Date.now() + ttlMs), + }, + }); + + if (usernameRequired) { + return reply.redirect(buildRedirectUrl(webAppUrl, { + ...redirectBaseParams, + status: "username_required", + reason: usernameReason ?? "invalid_login", + login, + pending: pendingKey, + })); + } + return reply.redirect(buildRedirectUrl(webAppUrl, { ...redirectBaseParams, pending: pendingKey })); + } + const tokenEnc = privacyKit.encodeBase64( encryptString(["auth", "external", providerId, "pending", pendingKey, publicKeyHex!], accessToken), ); diff --git a/apps/server/sources/app/api/routes/diagnostics/bugReportDiagnosticsRoutes.ts b/apps/server/sources/app/api/routes/diagnostics/bugReportDiagnosticsRoutes.ts index 34aefa8cf..111ec0fe7 100644 --- a/apps/server/sources/app/api/routes/diagnostics/bugReportDiagnosticsRoutes.ts +++ b/apps/server/sources/app/api/routes/diagnostics/bugReportDiagnosticsRoutes.ts @@ -6,6 +6,7 @@ import { redactBugReportSensitiveText } from "@happier-dev/protocol"; import { parseBooleanEnv, parseIntEnv } from "@/config/env"; import { isServerOwnerUserId, resolveServerOwnerUserIds } from "@/app/features/serverOwners"; +import { gateRateLimitConfig } from "@/app/api/utils/apiRateLimitPolicy"; import { type Fastify } from "../../types"; function resolveServerLogPath(): string | null { @@ -83,10 +84,10 @@ export function bugReportDiagnosticsRoutes(app: Fastify) { }, preHandler: app.authenticate, config: { - rateLimit: { + rateLimit: gateRateLimitConfig(process.env, { max: resolveDiagnosticsRateLimitMax(process.env.HAPPIER_BUG_REPORTS_SERVER_DIAGNOSTICS_RATE_LIMIT_MAX), timeWindow: resolveDiagnosticsRateLimitWindow(process.env.HAPPIER_BUG_REPORTS_SERVER_DIAGNOSTICS_RATE_LIMIT_WINDOW), - }, + }), }, }, async (request, reply) => { const enabled = parseBooleanEnv(process.env.HAPPIER_BUG_REPORTS_SERVER_DIAGNOSTICS_ENABLED, false); diff --git a/apps/server/sources/app/api/routes/features/featuresRoutes.integration.spec.ts b/apps/server/sources/app/api/routes/features/featuresRoutes.integration.spec.ts index 1be674e51..aa417a7d3 100644 --- a/apps/server/sources/app/api/routes/features/featuresRoutes.integration.spec.ts +++ b/apps/server/sources/app/api/routes/features/featuresRoutes.integration.spec.ts @@ -211,6 +211,75 @@ describe("featuresRoutes", () => { }); }); + describe("auth login", () => { + it("reports key-challenge login enabled by default", async () => { + const payload = await getFeaturesPayload(); + expect(payload.features.auth.login.keyChallenge.enabled).toBe(true); + expect(payload.capabilities.auth.login.methods).toEqual( + expect.arrayContaining([{ id: "key_challenge", enabled: true }]), + ); + }); + + it("reports key-challenge login disabled when HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED=0", async () => { + process.env.HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED = "0"; + + const payload = await getFeaturesPayload(); + expect(payload.features.auth.login.keyChallenge.enabled).toBe(false); + expect(payload.capabilities.auth.login.methods).toEqual( + expect.arrayContaining([{ id: "key_challenge", enabled: false }]), + ); + }); + }); + + describe("auth mtls", () => { + it("exposes mtls policy details under capabilities.auth.mtls.policy", async () => { + process.env.HAPPIER_FEATURE_AUTH_MTLS__ENABLED = "1"; + process.env.HAPPIER_FEATURE_AUTH_MTLS__MODE = "forwarded"; + process.env.HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS = "1"; + process.env.HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS = "CN=Example Root CA\ncn=Example Intermediate CA"; + process.env.HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS = "example.com, example.org"; + + const payload = await getFeaturesPayload(); + expect(payload.capabilities.auth.mtls).toEqual( + expect.objectContaining({ + policy: { + trustForwardedHeaders: true, + issuerAllowlist: { enabled: true, count: 2 }, + emailDomainAllowlist: { enabled: true, count: 2 }, + }, + }), + ); + }); + }); + + describe("encryption", () => { + it("reports required_e2ee by default", async () => { + const payload = await getFeaturesPayload(); + expect(payload.features.encryption.plaintextStorage.enabled).toBe(false); + expect(payload.features.encryption.accountOptOut.enabled).toBe(false); + expect(payload.capabilities.encryption).toEqual({ + storagePolicy: "required_e2ee", + allowAccountOptOut: false, + defaultAccountMode: "e2ee", + }); + }); + + it("reports plaintext storage enabled when policy is optional", async () => { + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + process.env.HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT = "1"; + process.env.HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE = "plain"; + + const payload = await getFeaturesPayload(); + expect(payload.features.encryption.plaintextStorage.enabled).toBe(true); + expect(payload.features.encryption.accountOptOut.enabled).toBe(true); + expect(payload.capabilities.encryption).toEqual({ + storagePolicy: "optional", + allowAccountOptOut: true, + defaultAccountMode: "plain", + }); + }); + }); + describe("auth misconfiguration", () => { it("surfaces misconfig when AUTH_PROVIDERS_CONFIG_JSON is invalid", async () => { process.env.AUTH_PROVIDERS_CONFIG_JSON = "{ definitely: not-json }"; diff --git a/apps/server/sources/app/api/routes/features/featuresRoutes.rateLimit.spec.ts b/apps/server/sources/app/api/routes/features/featuresRoutes.rateLimit.spec.ts new file mode 100644 index 000000000..af0348195 --- /dev/null +++ b/apps/server/sources/app/api/routes/features/featuresRoutes.rateLimit.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; + +class FakeApp { + public routes = new Map<string, any>(); + + get(path: string, opts: any, _handler: any) { + this.routes.set(`GET ${path}`, { opts }); + } +} + +describe("featuresRoutes rate limits", () => { + it("registers GET /v1/features with an explicit rate limit", async () => { + const { featuresRoutes } = await import("./featuresRoutes"); + const app = new FakeApp(); + featuresRoutes(app as any); + + const route = app.routes.get("GET /v1/features"); + expect(route?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/features/featuresRoutes.ts b/apps/server/sources/app/api/routes/features/featuresRoutes.ts index 539c34ff9..3ae0087ee 100644 --- a/apps/server/sources/app/api/routes/features/featuresRoutes.ts +++ b/apps/server/sources/app/api/routes/features/featuresRoutes.ts @@ -3,6 +3,7 @@ import { type Fastify } from '../../types'; import { featuresSchema } from '@/app/features/types'; import { resolveFeaturesFromEnv } from '@/app/features/registry'; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; export function featuresRoutes(app: Fastify) { app.get( @@ -13,6 +14,14 @@ export function featuresRoutes(app: Fastify) { 200: featuresSchema, }, }, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_FEATURES_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_FEATURES_RATE_LIMIT_WINDOW", + defaultMax: 120, + defaultWindow: "1 minute", + }), + }, }, async (_request, reply) => { return reply.send(resolveFeaturesFromEnv(process.env)); diff --git a/apps/server/sources/app/api/routes/feed/feedRoutes.rateLimit.spec.ts b/apps/server/sources/app/api/routes/feed/feedRoutes.rateLimit.spec.ts new file mode 100644 index 000000000..ea58922ed --- /dev/null +++ b/apps/server/sources/app/api/routes/feed/feedRoutes.rateLimit.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +class FakeApp { + public authenticate = vi.fn(); + public routes = new Map<string, any>(); + + get(path: string, opts: any, _handler: any) { + this.routes.set(`GET ${path}`, { opts }); + } +} + +describe("feedRoutes rate limits", () => { + it("registers GET /v1/feed with an explicit rate limit", async () => { + const { feedRoutes } = await import("./feedRoutes"); + const app = new FakeApp(); + feedRoutes(app as any); + + const route = app.routes.get("GET /v1/feed"); + expect(route?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/feed/feedRoutes.ts b/apps/server/sources/app/api/routes/feed/feedRoutes.ts index 12e69aca6..53c7191ca 100644 --- a/apps/server/sources/app/api/routes/feed/feedRoutes.ts +++ b/apps/server/sources/app/api/routes/feed/feedRoutes.ts @@ -4,10 +4,19 @@ import { FeedBodySchema } from "@/app/feed/types"; import { feedGet } from "@/app/feed/feedGet"; import { Context } from "@/context"; import { db } from "@/storage/db"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; export function feedRoutes(app: Fastify) { app.get('/v1/feed', { preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_FEED_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_FEED_RATE_LIMIT_WINDOW", + defaultMax: 300, + defaultWindow: "1 minute", + }), + }, schema: { querystring: z.object({ before: z.string().optional(), @@ -37,4 +46,4 @@ export function feedRoutes(app: Fastify) { }); return reply.send({ items: items.items, hasMore: items.hasMore }); }); -} \ No newline at end of file +} diff --git a/apps/server/sources/app/api/routes/kv/kvRoutes.rateLimit.spec.ts b/apps/server/sources/app/api/routes/kv/kvRoutes.rateLimit.spec.ts new file mode 100644 index 000000000..463419f87 --- /dev/null +++ b/apps/server/sources/app/api/routes/kv/kvRoutes.rateLimit.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; + +class FakeApp { + public authenticate = vi.fn(); + public routes = new Map<string, any>(); + + get(path: string, opts: any, _handler: any) { + this.routes.set(`GET ${path}`, { opts }); + } + post(path: string, opts: any, _handler: any) { + this.routes.set(`POST ${path}`, { opts }); + } +} + +describe("kvRoutes rate limits", () => { + it("registers GET /v1/kv with an explicit rate limit", async () => { + const { kvRoutes } = await import("./kvRoutes"); + const app = new FakeApp(); + kvRoutes(app as any); + + const route = app.routes.get("GET /v1/kv"); + expect(route?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/kv/kvRoutes.ts b/apps/server/sources/app/api/routes/kv/kvRoutes.ts index 2b619a26e..c6fdf3262 100644 --- a/apps/server/sources/app/api/routes/kv/kvRoutes.ts +++ b/apps/server/sources/app/api/routes/kv/kvRoutes.ts @@ -5,6 +5,7 @@ import { kvList } from "@/app/kv/kvList"; import { kvBulkGet } from "@/app/kv/kvBulkGet"; import { kvMutate } from "@/app/kv/kvMutate"; import { log } from "@/utils/logging/log"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; export function kvRoutes(app: Fastify) { // GET /v1/kv/:key - Get single value @@ -49,6 +50,14 @@ export function kvRoutes(app: Fastify) { // GET /v1/kv - List key-value pairs with optional prefix filter app.get('/v1/kv', { preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_KV_LIST_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_KV_LIST_RATE_LIMIT_WINDOW", + defaultMax: 600, + defaultWindow: "1 minute", + }), + }, schema: { querystring: z.object({ prefix: z.string().optional(), @@ -169,4 +178,4 @@ export function kvRoutes(app: Fastify) { return reply.code(500).send({ error: 'Failed to mutate values' }); } }); -} \ No newline at end of file +} diff --git a/apps/server/sources/app/api/routes/machines/machinesRoutes.rateLimit.spec.ts b/apps/server/sources/app/api/routes/machines/machinesRoutes.rateLimit.spec.ts new file mode 100644 index 000000000..684ef37b0 --- /dev/null +++ b/apps/server/sources/app/api/routes/machines/machinesRoutes.rateLimit.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; + +class FakeApp { + public authenticate = vi.fn(); + public routes = new Map<string, any>(); + + get(path: string, opts: any, _handler: any) { + this.routes.set(`GET ${path}`, { opts }); + } + post(path: string, opts: any, _handler: any) { + this.routes.set(`POST ${path}`, { opts }); + } +} + +describe("machinesRoutes rate limits", () => { + it("registers GET /v1/machines with an explicit rate limit", async () => { + const { machinesRoutes } = await import("./machinesRoutes"); + const app = new FakeApp(); + machinesRoutes(app as any); + + const route = app.routes.get("GET /v1/machines"); + expect(route?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/machines/machinesRoutes.ts b/apps/server/sources/app/api/routes/machines/machinesRoutes.ts index 754fcc0fc..d99d2eeab 100644 --- a/apps/server/sources/app/api/routes/machines/machinesRoutes.ts +++ b/apps/server/sources/app/api/routes/machines/machinesRoutes.ts @@ -9,6 +9,7 @@ import { activityCache } from "@/app/presence/sessionCache"; import { afterTx, inTx } from "@/storage/inTx"; import { markAccountChanged } from "@/app/changes/markAccountChanged"; import { timingSafeEqual } from "node:crypto"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; function bytesEqual(a: Uint8Array | null, b: Uint8Array | null) { if (a === b) return true; @@ -47,6 +48,12 @@ function serializeMachineRow(row: { }; } +function isMachineRevokedError(value: unknown): value is { error: 'machine_revoked' } { + if (typeof value !== 'object' || value === null) return false; + if (!('error' in value)) return false; + return (value as { error?: unknown }).error === 'machine_revoked'; +} + export function machinesRoutes(app: Fastify) { app.post('/v1/machines', { preHandler: app.authenticate, @@ -148,7 +155,7 @@ export function machinesRoutes(app: Fastify) { return reply.code(404).send({ error: "machine_not_found" }); } - if ('error' in updated && updated.error === 'machine_revoked') { + if (isMachineRevokedError(updated)) { return reply.code(410).send({ error: 'machine_revoked' }); } @@ -314,6 +321,14 @@ export function machinesRoutes(app: Fastify) { // Machines API app.get('/v1/machines', { preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_MACHINES_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_MACHINES_RATE_LIMIT_WINDOW", + defaultMax: 300, + defaultWindow: "1 minute", + }), + }, }, async (request, reply) => { const userId = request.userId; @@ -328,6 +343,14 @@ export function machinesRoutes(app: Fastify) { // GET /v1/machines/:id - Get single machine by ID app.get('/v1/machines/:id', { preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_MACHINES_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_MACHINES_RATE_LIMIT_WINDOW", + defaultMax: 300, + defaultWindow: "1 minute", + }), + }, schema: { params: z.object({ id: z.string() diff --git a/apps/server/sources/app/api/routes/session/pendingRoutes.enqueue.integration.spec.ts b/apps/server/sources/app/api/routes/session/pendingRoutes.enqueue.integration.spec.ts new file mode 100644 index 000000000..d18c0f58e --- /dev/null +++ b/apps/server/sources/app/api/routes/session/pendingRoutes.enqueue.integration.spec.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const enqueuePendingMessage = vi.fn(); + +vi.mock("@/app/session/pending/pendingMessageService", () => ({ + enqueuePendingMessage, +})); + +class FakeApp { + public authenticate = vi.fn(); + public routes = new Map<string, any>(); + + get(path: string, _opts: any, handler: any) { + this.routes.set(`GET ${path}`, handler); + } + post(path: string, _opts: any, handler: any) { + this.routes.set(`POST ${path}`, handler); + } + put(path: string, _opts: any, handler: any) { + this.routes.set(`PUT ${path}`, handler); + } + patch(path: string, _opts: any, handler: any) { + this.routes.set(`PATCH ${path}`, handler); + } + delete(path: string, _opts: any, handler: any) { + this.routes.set(`DELETE ${path}`, handler); + } +} + +function replyStub() { + const reply: any = { send: vi.fn((p: any) => p), code: vi.fn(() => reply) }; + return reply; +} + +describe("sessionPendingRoutes (enqueue)", () => { + beforeEach(() => { + vi.resetModules(); + enqueuePendingMessage.mockReset(); + }); + + it("forwards plain content payloads to enqueuePendingMessage", async () => { + const createdAt = new Date(1); + enqueuePendingMessage.mockResolvedValueOnce({ + ok: true, + didWrite: true, + pending: { + localId: "l1", + content: { t: "plain", v: { type: "user", text: "hi" } }, + status: "queued", + position: 1, + createdAt, + updatedAt: createdAt, + discardedAt: null, + discardedReason: null, + authorAccountId: "actor", + }, + pendingCount: 1, + pendingVersion: 1, + participantCursors: [], + }); + + const { sessionPendingRoutes } = await import("./pendingRoutes"); + const app = new FakeApp(); + sessionPendingRoutes(app as any); + + const handler = app.routes.get("POST /v2/sessions/:sessionId/pending"); + const reply = replyStub(); + await handler( + { + userId: "actor", + params: { sessionId: "s1" }, + body: { localId: "l1", content: { t: "plain", v: { type: "user", text: "hi" } } }, + }, + reply, + ); + + expect(enqueuePendingMessage).toHaveBeenCalledWith({ + actorUserId: "actor", + sessionId: "s1", + localId: "l1", + content: { t: "plain", v: { type: "user", text: "hi" } }, + }); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + didWrite: true, + pendingCount: 1, + pendingVersion: 1, + }), + ); + }); + + it("includes a stable error code when enqueuePendingMessage returns invalid-params with a code", async () => { + enqueuePendingMessage.mockResolvedValueOnce({ + ok: false, + error: "invalid-params", + code: "session_encryption_mode_mismatch", + }); + + const { sessionPendingRoutes } = await import("./pendingRoutes"); + const app = new FakeApp(); + sessionPendingRoutes(app as any); + + const handler = app.routes.get("POST /v2/sessions/:sessionId/pending"); + const reply = replyStub(); + await handler( + { + userId: "actor", + params: { sessionId: "s1" }, + body: { localId: "l1", ciphertext: "cipher" }, + }, + reply, + ); + + expect(reply.code).toHaveBeenCalledWith(400); + expect(reply.send).toHaveBeenCalledWith({ + error: "invalid-params", + code: "session_encryption_mode_mismatch", + }); + }); +}); diff --git a/apps/server/sources/app/api/routes/session/pendingRoutes.rateLimit.spec.ts b/apps/server/sources/app/api/routes/session/pendingRoutes.rateLimit.spec.ts new file mode 100644 index 000000000..ed20c2318 --- /dev/null +++ b/apps/server/sources/app/api/routes/session/pendingRoutes.rateLimit.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; + +class FakeApp { + public authenticate = vi.fn(); + public routes = new Map<string, any>(); + + get(path: string, opts: any, handler: any) { + this.routes.set(`GET ${path}`, { opts, handler }); + } + post(path: string, opts: any, handler: any) { + this.routes.set(`POST ${path}`, { opts, handler }); + } + put(path: string, opts: any, handler: any) { + this.routes.set(`PUT ${path}`, { opts, handler }); + } + patch(path: string, opts: any, handler: any) { + this.routes.set(`PATCH ${path}`, { opts, handler }); + } + delete(path: string, opts: any, handler: any) { + this.routes.set(`DELETE ${path}`, { opts, handler }); + } +} + +describe("sessionPendingRoutes rate limits", () => { + it("registers pending routes with explicit rate limits", async () => { + const { sessionPendingRoutes } = await import("./pendingRoutes"); + const app = new FakeApp(); + sessionPendingRoutes(app as any); + + const list = app.routes.get("GET /v2/sessions/:sessionId/pending"); + expect(list?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + + const materialize = app.routes.get("POST /v2/sessions/:sessionId/pending/materialize-next"); + expect(materialize?.opts?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/session/pendingRoutes.ts b/apps/server/sources/app/api/routes/session/pendingRoutes.ts index 6dbb6a17d..f574c1b86 100644 --- a/apps/server/sources/app/api/routes/session/pendingRoutes.ts +++ b/apps/server/sources/app/api/routes/session/pendingRoutes.ts @@ -14,6 +14,8 @@ import { } from "@/app/session/pending/pendingMessageService"; import { randomKeyNaked } from "@/utils/keys/randomKeyNaked"; import { log } from "@/utils/logging/log"; +import { SessionStoredMessageContentSchema } from "@happier-dev/protocol"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; function toPendingJson(row: PendingMessageRow) { return { @@ -29,6 +31,13 @@ function toPendingJson(row: PendingMessageRow) { }; } +function getOptionalErrorCode(value: unknown): string | undefined { + if (!value || typeof value !== "object") return undefined; + if (!("code" in value)) return undefined; + const code = (value as { code?: unknown }).code; + return typeof code === "string" && code.length > 0 ? code : undefined; +} + async function emitPendingChanged(params: { sessionId: string; changedByAccountId: string; @@ -81,6 +90,14 @@ export function sessionPendingRoutes(app: Fastify) { }) .optional(), }, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_SESSION_PENDING_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_SESSION_PENDING_RATE_LIMIT_WINDOW", + defaultMax: 600, + defaultWindow: "1 minute", + }), + }, }, async (request, reply) => { const { sessionId } = request.params; @@ -94,7 +111,12 @@ export function sessionPendingRoutes(app: Fastify) { }); if (!res.ok) { - if (res.error === "invalid-params") return reply.code(400).send({ error: res.error }); + if (res.error === "invalid-params") { + const payload: { error: string; code?: string } = { error: res.error }; + const code = getOptionalErrorCode(res); + if (code) payload.code = code; + return reply.code(400).send(payload); + } if (res.error === "forbidden") return reply.code(403).send({ error: res.error }); if (res.error === "session-not-found") return reply.code(404).send({ error: res.error }); return reply.code(500).send({ error: res.error }); @@ -110,25 +132,55 @@ export function sessionPendingRoutes(app: Fastify) { preHandler: app.authenticate, schema: { params: z.object({ sessionId: z.string() }), - body: z.object({ - ciphertext: z.string().min(1), - localId: z.string().min(1), - }), + body: z.union([ + z.object({ + ciphertext: z.string().min(1), + localId: z.string().min(1), + }), + z.object({ + content: SessionStoredMessageContentSchema, + localId: z.string().min(1), + }), + ]), }, }, async (request, reply) => { const { sessionId } = request.params; - const { ciphertext, localId } = request.body; + const body = request.body as unknown; + const localId = + body && typeof body === "object" && "localId" in body && typeof (body as { localId?: unknown }).localId === "string" + ? (body as { localId: string }).localId + : ""; + const ciphertext = + body && typeof body === "object" && "ciphertext" in body && typeof (body as { ciphertext?: unknown }).ciphertext === "string" + ? (body as { ciphertext: string }).ciphertext + : null; + const content = + body && typeof body === "object" && "content" in body + ? ((body as { content: PrismaJson.SessionPendingMessageContent }).content ?? null) + : null; - const res = await enqueuePendingMessage({ - actorUserId: request.userId, - sessionId, - localId, - ciphertext, - }); + const res = await (content + ? enqueuePendingMessage({ + actorUserId: request.userId, + sessionId, + localId, + content, + }) + : enqueuePendingMessage({ + actorUserId: request.userId, + sessionId, + localId, + ciphertext: ciphertext ?? "", + })); if (!res.ok) { - if (res.error === "invalid-params") return reply.code(400).send({ error: res.error }); + if (res.error === "invalid-params") { + const payload: { error: string; code?: string } = { error: res.error }; + const code = getOptionalErrorCode(res); + if (code) payload.code = code; + return reply.code(400).send(payload); + } if (res.error === "forbidden") return reply.code(403).send({ error: res.error }); if (res.error === "session-not-found") return reply.code(404).send({ error: res.error }); return reply.code(500).send({ error: res.error }); @@ -157,16 +209,34 @@ export function sessionPendingRoutes(app: Fastify) { preHandler: app.authenticate, schema: { params: z.object({ sessionId: z.string(), localId: z.string() }), - body: z.object({ ciphertext: z.string().min(1) }), + body: z.union([ + z.object({ ciphertext: z.string().min(1) }), + z.object({ content: SessionStoredMessageContentSchema }), + ]), }, }, async (request, reply) => { const { sessionId, localId } = request.params; - const { ciphertext } = request.body; + const body = request.body as unknown; + const ciphertext = + body && typeof body === "object" && "ciphertext" in body && typeof (body as { ciphertext?: unknown }).ciphertext === "string" + ? (body as { ciphertext: string }).ciphertext + : null; + const content = + body && typeof body === "object" && "content" in body + ? ((body as { content: PrismaJson.SessionPendingMessageContent }).content ?? null) + : null; - const res = await updatePendingMessage({ actorUserId: request.userId, sessionId, localId, ciphertext }); + const res = await (content + ? updatePendingMessage({ actorUserId: request.userId, sessionId, localId, content }) + : updatePendingMessage({ actorUserId: request.userId, sessionId, localId, ciphertext: ciphertext ?? "" })); if (!res.ok) { - if (res.error === "invalid-params") return reply.code(400).send({ error: res.error }); + if (res.error === "invalid-params") { + const payload: { error: string; code?: string } = { error: res.error }; + const code = getOptionalErrorCode(res); + if (code) payload.code = code; + return reply.code(400).send(payload); + } if (res.error === "forbidden") return reply.code(403).send({ error: res.error }); if (res.error === "session-not-found") return reply.code(404).send({ error: res.error }); return reply.code(500).send({ error: res.error }); @@ -193,7 +263,12 @@ export function sessionPendingRoutes(app: Fastify) { const { sessionId, localId } = request.params; const res = await deletePendingMessage({ actorUserId: request.userId, sessionId, localId }); if (!res.ok) { - if (res.error === "invalid-params") return reply.code(400).send({ error: res.error }); + if (res.error === "invalid-params") { + const payload: { error: string; code?: string } = { error: res.error }; + const code = getOptionalErrorCode(res); + if (code) payload.code = code; + return reply.code(400).send(payload); + } if (res.error === "forbidden") return reply.code(403).send({ error: res.error }); if (res.error === "session-not-found") return reply.code(404).send({ error: res.error }); return reply.code(500).send({ error: res.error }); @@ -305,6 +380,14 @@ export function sessionPendingRoutes(app: Fastify) { { preHandler: app.authenticate, schema: { params: z.object({ sessionId: z.string() }) }, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_SESSION_PENDING_MATERIALIZE_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_SESSION_PENDING_MATERIALIZE_RATE_LIMIT_WINDOW", + defaultMax: 120, + defaultWindow: "1 minute", + }), + }, }, async (request, reply) => { const { sessionId } = request.params; diff --git a/apps/server/sources/app/api/routes/session/registerSessionCreateOrLoadRoute.ts b/apps/server/sources/app/api/routes/session/registerSessionCreateOrLoadRoute.ts index f4cfce530..cc16e1e99 100644 --- a/apps/server/sources/app/api/routes/session/registerSessionCreateOrLoadRoute.ts +++ b/apps/server/sources/app/api/routes/session/registerSessionCreateOrLoadRoute.ts @@ -4,6 +4,12 @@ import { afterTx, inTx } from "@/storage/inTx"; import { log } from "@/utils/logging/log"; import { randomKeyNaked } from "@/utils/keys/randomKeyNaked"; import { z } from "zod"; +import { readEncryptionFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { + isSessionEncryptionModeAllowedByStoragePolicy, + resolveEffectiveDefaultAccountEncryptionMode, +} from "@happier-dev/protocol"; +import { resolveRequestedSessionModeRejectionCode } from "@/app/session/encryptionRejectionCodes"; import { type Fastify } from "../../types"; @@ -14,13 +20,26 @@ export function registerSessionCreateOrLoadRoute(app: Fastify) { tag: z.string(), metadata: z.string(), agentState: z.string().nullish(), - dataEncryptionKey: z.string().nullish() + dataEncryptionKey: z.string().nullish(), + encryptionMode: z.enum(["e2ee", "plain"]).optional(), }) }, preHandler: app.authenticate }, async (request, reply) => { const userId = request.userId; - const { tag, metadata, dataEncryptionKey } = request.body; + const { tag, metadata, agentState, dataEncryptionKey } = request.body; + const requestedEncryptionMode = request.body.encryptionMode; + const policy = readEncryptionFeatureEnv(process.env); + + if ( + (requestedEncryptionMode === "plain" || requestedEncryptionMode === "e2ee") && + !isSessionEncryptionModeAllowedByStoragePolicy(policy.storagePolicy, requestedEncryptionMode) + ) { + return reply.code(400).send({ + error: "invalid-params", + code: resolveRequestedSessionModeRejectionCode({ storagePolicy: policy.storagePolicy }), + }); + } const resolvedSession = await inTx(async (tx) => { const existing = await tx.session.findFirst({ @@ -39,14 +58,43 @@ export function registerSessionCreateOrLoadRoute(app: Fastify) { } log({ module: "session-create", userId, tag }, `Creating new session for user ${userId} with tag ${tag}`); + + const account = await tx.account.findUnique({ + where: { id: userId }, + select: { encryptionMode: true }, + }); + const accountEncryptionMode: "e2ee" | "plain" = account?.encryptionMode === "plain" ? "plain" : "e2ee"; + + const defaultEncryptionMode = resolveEffectiveDefaultAccountEncryptionMode( + policy.storagePolicy, + policy.defaultAccountMode, + ); + + const requestedOrAccountOrDefault: "e2ee" | "plain" = + requestedEncryptionMode === "plain" || requestedEncryptionMode === "e2ee" + ? requestedEncryptionMode + : accountEncryptionMode ?? defaultEncryptionMode; + + const effectiveEncryptionMode: "e2ee" | "plain" = + policy.storagePolicy === "required_e2ee" + ? "e2ee" + : policy.storagePolicy === "plaintext_only" + ? "plain" + : requestedOrAccountOrDefault; + const created = await tx.session.create({ data: { accountId: userId, tag, + encryptionMode: effectiveEncryptionMode, metadata, - dataEncryptionKey: dataEncryptionKey - ? new Uint8Array(Buffer.from(dataEncryptionKey, "base64")) - : undefined, + agentState: agentState ?? null, + dataEncryptionKey: + effectiveEncryptionMode === "plain" + ? undefined + : dataEncryptionKey + ? new Uint8Array(Buffer.from(dataEncryptionKey, "base64")) + : undefined, }, }); @@ -80,6 +128,7 @@ export function registerSessionCreateOrLoadRoute(app: Fastify) { session: { id: resolvedSession.id, seq: resolvedSession.seq, + encryptionMode: resolvedSession.encryptionMode, metadata: resolvedSession.metadata, metadataVersion: resolvedSession.metadataVersion, agentState: resolvedSession.agentState, diff --git a/apps/server/sources/app/api/routes/session/registerSessionListingRoutes.ts b/apps/server/sources/app/api/routes/session/registerSessionListingRoutes.ts index f66ccb95b..7056ca6a9 100644 --- a/apps/server/sources/app/api/routes/session/registerSessionListingRoutes.ts +++ b/apps/server/sources/app/api/routes/session/registerSessionListingRoutes.ts @@ -1,6 +1,14 @@ import type { Prisma } from "@prisma/client"; import { z } from "zod"; +import { + V2SessionByIdNotFoundSchema, + V2SessionByIdResponseSchema, + V2SessionListResponseSchema, + decodeV2SessionListCursorV1, + encodeV2SessionListCursorV1, +} from "@happier-dev/protocol"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; import { PROFILE_SELECT, toShareUserProfile } from "@/app/share/types"; import { db } from "@/storage/db"; import { type Fastify } from "../../types"; @@ -12,6 +20,14 @@ function encodeDataEncryptionKey(value: Uint8Array | null): string | null { export function registerSessionListingRoutes(app: Fastify) { app.get('/v1/sessions', { preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_SESSIONS_LIST_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_SESSIONS_LIST_RATE_LIMIT_WINDOW", + defaultMax: 300, + defaultWindow: "1 minute", + }), + }, }, async (request, reply) => { const userId = request.userId; @@ -26,6 +42,7 @@ export function registerSessionListingRoutes(app: Fastify) { createdAt: true, updatedAt: true, archivedAt: true, + encryptionMode: true, metadata: true, metadataVersion: true, agentState: true, @@ -54,6 +71,7 @@ export function registerSessionListingRoutes(app: Fastify) { createdAt: true, updatedAt: true, archivedAt: true, + encryptionMode: true, metadata: true, metadataVersion: true, agentState: true, @@ -77,6 +95,7 @@ export function registerSessionListingRoutes(app: Fastify) { active: v.active, activeAt: v.lastActiveAt.getTime(), archivedAt: v.archivedAt?.getTime() ?? null, + encryptionMode: v.encryptionMode === "plain" ? "plain" : "e2ee", metadata: v.metadata, metadataVersion: v.metadataVersion, agentState: v.agentState, @@ -96,13 +115,17 @@ export function registerSessionListingRoutes(app: Fastify) { active: v.active, activeAt: v.lastActiveAt.getTime(), archivedAt: v.archivedAt?.getTime() ?? null, + encryptionMode: v.encryptionMode === "plain" ? "plain" : "e2ee", metadata: v.metadata, metadataVersion: v.metadataVersion, agentState: v.agentState, agentStateVersion: v.agentStateVersion, pendingCount: v.pendingCount, pendingVersion: v.pendingVersion, - dataEncryptionKey: Buffer.from(share.encryptedDataKey).toString('base64'), + dataEncryptionKey: + v.encryptionMode === "plain" + ? null + : (share.encryptedDataKey ? Buffer.from(share.encryptedDataKey).toString('base64') : null), lastMessage: null, owner: share.sharedByUserId, ownerProfile: toShareUserProfile(share.sharedByUser), @@ -120,6 +143,9 @@ export function registerSessionListingRoutes(app: Fastify) { app.get('/v2/sessions/active', { preHandler: app.authenticate, schema: { + response: { + 200: V2SessionListResponseSchema, + }, querystring: z.object({ limit: z.coerce.number().int().min(1).max(500).default(150) }).optional() @@ -142,6 +168,7 @@ export function registerSessionListingRoutes(app: Fastify) { createdAt: true, updatedAt: true, archivedAt: true, + encryptionMode: true, metadata: true, metadataVersion: true, agentState: true, @@ -161,6 +188,7 @@ export function registerSessionListingRoutes(app: Fastify) { active: v.active, activeAt: v.lastActiveAt.getTime(), archivedAt: v.archivedAt?.getTime() ?? null, + encryptionMode: v.encryptionMode === "plain" ? "plain" : "e2ee", metadata: v.metadata, metadataVersion: v.metadataVersion, agentState: v.agentState, @@ -173,22 +201,34 @@ export function registerSessionListingRoutes(app: Fastify) { app.get('/v2/sessions', { preHandler: app.authenticate, schema: { + response: { + 200: V2SessionListResponseSchema, + 400: z.object({ error: z.literal('Invalid cursor format') }), + }, querystring: z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).default(50), }).optional() - } + }, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_SESSIONS_LIST_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_SESSIONS_LIST_RATE_LIMIT_WINDOW", + defaultMax: 300, + defaultWindow: "1 minute", + }), + }, }, async (request, reply) => { const userId = request.userId; const { cursor, limit = 50 } = request.query || {}; let cursorSessionId: string | undefined; if (cursor) { - if (cursor.startsWith('cursor_v1_')) { - cursorSessionId = cursor.substring(10); - } else { + const decoded = decodeV2SessionListCursorV1(cursor); + if (!decoded) { return reply.code(400).send({ error: 'Invalid cursor format' }); } + cursorSessionId = decoded; } const where: Prisma.SessionWhereInput = { @@ -212,6 +252,7 @@ export function registerSessionListingRoutes(app: Fastify) { createdAt: true, updatedAt: true, archivedAt: true, + encryptionMode: true, metadata: true, metadataVersion: true, agentState: true, @@ -237,7 +278,7 @@ export function registerSessionListingRoutes(app: Fastify) { let nextCursor: string | null = null; if (hasNext && resultSessions.length > 0) { const lastSession = resultSessions[resultSessions.length - 1]; - nextCursor = `cursor_v1_${lastSession.id}`; + nextCursor = encodeV2SessionListCursorV1(lastSession.id); } return reply.send({ @@ -249,6 +290,7 @@ export function registerSessionListingRoutes(app: Fastify) { active: v.active, activeAt: v.lastActiveAt.getTime(), archivedAt: v.archivedAt?.getTime() ?? null, + encryptionMode: v.encryptionMode === "plain" ? "plain" : "e2ee", metadata: v.metadata, metadataVersion: v.metadataVersion, agentState: v.agentState, @@ -275,6 +317,10 @@ export function registerSessionListingRoutes(app: Fastify) { app.get('/v2/sessions/archived', { preHandler: app.authenticate, schema: { + response: { + 200: V2SessionListResponseSchema, + 400: z.object({ error: z.literal('Invalid cursor format') }), + }, querystring: z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).default(50), @@ -286,11 +332,11 @@ export function registerSessionListingRoutes(app: Fastify) { let cursorSessionId: string | undefined; if (cursor) { - if (cursor.startsWith('cursor_v1_')) { - cursorSessionId = cursor.substring(10); - } else { + const decoded = decodeV2SessionListCursorV1(cursor); + if (!decoded) { return reply.code(400).send({ error: 'Invalid cursor format' }); } + cursorSessionId = decoded; } const where: Prisma.SessionWhereInput = { @@ -315,6 +361,7 @@ export function registerSessionListingRoutes(app: Fastify) { createdAt: true, updatedAt: true, archivedAt: true, + encryptionMode: true, metadata: true, metadataVersion: true, agentState: true, @@ -340,7 +387,7 @@ export function registerSessionListingRoutes(app: Fastify) { let nextCursor: string | null = null; if (hasNext && resultSessions.length > 0) { const lastSession = resultSessions[resultSessions.length - 1]; - nextCursor = `cursor_v1_${lastSession.id}`; + nextCursor = encodeV2SessionListCursorV1(lastSession.id); } return reply.send({ @@ -352,6 +399,7 @@ export function registerSessionListingRoutes(app: Fastify) { active: v.active, activeAt: v.lastActiveAt.getTime(), archivedAt: v.archivedAt?.getTime() ?? null, + encryptionMode: v.encryptionMode === "plain" ? "plain" : "e2ee", metadata: v.metadata, metadataVersion: v.metadataVersion, agentState: v.agentState, @@ -382,31 +430,8 @@ export function registerSessionListingRoutes(app: Fastify) { sessionId: z.string(), }), response: { - 200: z.object({ - session: z.object({ - id: z.string(), - seq: z.number(), - createdAt: z.number(), - updatedAt: z.number(), - active: z.boolean(), - activeAt: z.number(), - archivedAt: z.number().nullable(), - metadata: z.string(), - metadataVersion: z.number(), - agentState: z.string().nullable(), - agentStateVersion: z.number(), - pendingCount: z.number().int().min(0), - pendingVersion: z.number().int().min(0), - dataEncryptionKey: z.string().nullable(), - share: z - .object({ - accessLevel: z.string(), - canApprovePermissions: z.boolean(), - }) - .nullable(), - }), - }), - 404: z.object({ error: z.literal('Session not found') }), + 200: V2SessionByIdResponseSchema, + 404: V2SessionByIdNotFoundSchema, }, }, }, async (request, reply) => { @@ -428,6 +453,7 @@ export function registerSessionListingRoutes(app: Fastify) { createdAt: true, updatedAt: true, archivedAt: true, + encryptionMode: true, metadata: true, metadataVersion: true, agentState: true, @@ -461,6 +487,7 @@ export function registerSessionListingRoutes(app: Fastify) { active: session.active, activeAt: session.lastActiveAt.getTime(), archivedAt: session.archivedAt?.getTime() ?? null, + encryptionMode: session.encryptionMode === "plain" ? "plain" : "e2ee", metadata: session.metadata, metadataVersion: session.metadataVersion, agentState: session.agentState, diff --git a/apps/server/sources/app/api/routes/session/registerSessionMessageRoutes.ts b/apps/server/sources/app/api/routes/session/registerSessionMessageRoutes.ts index a347697dd..d7e19f839 100644 --- a/apps/server/sources/app/api/routes/session/registerSessionMessageRoutes.ts +++ b/apps/server/sources/app/api/routes/session/registerSessionMessageRoutes.ts @@ -3,13 +3,80 @@ import { z } from "zod"; import { buildNewMessageUpdate, eventRouter } from "@/app/events/eventRouter"; import { catchupFollowupFetchesCounter, catchupFollowupReturnedCounter } from "@/app/monitoring/metrics2"; +import { SessionStoredMessageContentSchema } from "@happier-dev/protocol"; import { createSessionMessage } from "@/app/session/sessionWriteService"; import { checkSessionAccess } from "@/app/share/accessControl"; import { db } from "@/storage/db"; import { randomKeyNaked } from "@/utils/keys/randomKeyNaked"; +import { resolveRouteRateLimit } from "@/app/api/utils/apiRateLimitPolicy"; import { type Fastify } from "../../types"; export function registerSessionMessageRoutes(app: Fastify) { + app.get('/v2/sessions/:sessionId/messages/by-local-id/:localId', { + schema: { + params: z.object({ + sessionId: z.string(), + localId: z.string().min(1), + }), + response: { + 200: z.object({ + message: z.object({ + id: z.string(), + seq: z.number().int().min(0), + localId: z.string().nullable(), + content: SessionStoredMessageContentSchema, + createdAt: z.number().int().min(0), + updatedAt: z.number().int().min(0), + }).passthrough(), + }).passthrough(), + 404: z.object({ error: z.string() }).passthrough(), + }, + }, + preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_SESSION_MESSAGES_BY_LOCAL_ID_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_SESSION_MESSAGES_BY_LOCAL_ID_RATE_LIMIT_WINDOW", + defaultMax: 600, + defaultWindow: "1 minute", + }), + }, + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, localId } = request.params; + + const access = await checkSessionAccess(userId, sessionId); + if (!access) { + return reply.code(404).send({ error: 'Session not found' }); + } + + const row = await db.sessionMessage.findUnique({ + where: { sessionId_localId: { sessionId, localId } }, + select: { + id: true, + seq: true, + localId: true, + content: true, + createdAt: true, + updatedAt: true, + }, + }); + if (!row) { + return reply.code(404).send({ error: 'Message not found' }); + } + + return reply.send({ + message: { + id: row.id, + seq: row.seq, + localId: row.localId, + content: row.content, + createdAt: row.createdAt.getTime(), + updatedAt: row.updatedAt.getTime(), + }, + }); + }); + app.get('/v1/sessions/:sessionId/messages', { schema: { params: z.object({ @@ -28,7 +95,15 @@ export function registerSessionMessageRoutes(app: Fastify) { } }).optional(), }, - preHandler: app.authenticate + preHandler: app.authenticate, + config: { + rateLimit: resolveRouteRateLimit(process.env, { + maxEnvKey: "HAPPIER_SESSION_MESSAGES_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_SESSION_MESSAGES_RATE_LIMIT_WINDOW", + defaultMax: 600, + defaultWindow: "1 minute", + }), + }, }, async (request, reply) => { const userId = request.userId; const { sessionId } = request.params; @@ -105,21 +180,29 @@ export function registerSessionMessageRoutes(app: Fastify) { params: z.object({ sessionId: z.string(), }), - body: z.object({ - ciphertext: z.string(), - localId: z.string().optional(), - }), - response: { - 200: z.object({ - didWrite: z.boolean(), - message: z.object({ - id: z.string(), - seq: z.number(), - localId: z.string().nullable(), - createdAt: z.number(), - }), + body: z.union([ + z.object({ + ciphertext: z.string().min(1), + localId: z.string().optional(), + }), + z.object({ + content: SessionStoredMessageContentSchema, + localId: z.string().optional(), }), - 400: z.object({ error: z.literal('Invalid parameters') }), + ]), + response: { + 200: z + .object({ + didWrite: z.boolean(), + message: z.object({ + id: z.string(), + seq: z.number().int().min(0), + localId: z.string().nullable(), + createdAt: z.number().int().min(0), + }), + }) + .passthrough(), + 400: z.object({ error: z.literal('Invalid parameters'), code: z.string().optional() }).passthrough(), 403: z.object({ error: z.literal('Forbidden') }), 404: z.object({ error: z.literal('Session not found') }), 500: z.object({ error: z.literal('Failed to create message') }), @@ -128,7 +211,8 @@ export function registerSessionMessageRoutes(app: Fastify) { }, async (request, reply) => { const userId = request.userId; const { sessionId } = request.params; - const { ciphertext, localId } = request.body; + const body = request.body as Readonly<{ localId?: string } & ({ ciphertext: string } | { content: PrismaJson.SessionMessageContent })>; + const localId = typeof body.localId === "string" ? body.localId : undefined; const headerKey = request.headers["idempotency-key"]; const idempotencyKey = @@ -140,15 +224,27 @@ export function registerSessionMessageRoutes(app: Fastify) { const effectiveLocalId = localId ?? idempotencyKey ?? null; - const result = await createSessionMessage({ - actorUserId: userId, - sessionId, - ciphertext, - localId: effectiveLocalId, - }); + const result = + "content" in body + ? await createSessionMessage({ + actorUserId: userId, + sessionId, + content: body.content, + localId: effectiveLocalId, + }) + : await createSessionMessage({ + actorUserId: userId, + sessionId, + ciphertext: body.ciphertext, + localId: effectiveLocalId, + }); if (!result.ok) { - if (result.error === "invalid-params") return reply.code(400).send({ error: "Invalid parameters" }); + if (result.error === "invalid-params") { + const payload: { error: "Invalid parameters"; code?: string } = { error: "Invalid parameters" }; + if ("code" in result && typeof result.code === "string") payload.code = result.code; + return reply.code(400).send(payload); + } if (result.error === "forbidden") return reply.code(403).send({ error: "Forbidden" }); if (result.error === "session-not-found") return reply.code(404).send({ error: "Session not found" }); return reply.code(500).send({ error: "Failed to create message" }); diff --git a/apps/server/sources/app/api/routes/session/sessionRoutes.listing.rateLimit.spec.ts b/apps/server/sources/app/api/routes/session/sessionRoutes.listing.rateLimit.spec.ts new file mode 100644 index 000000000..f15f509e6 --- /dev/null +++ b/apps/server/sources/app/api/routes/session/sessionRoutes.listing.rateLimit.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { registerSessionRoutesAndGetHandler } from "./sessionRoutes.testkit"; + +describe("sessionRoutes listing rate limits", () => { + it("registers session listing routes with explicit rate limits", async () => { + const { app: v1App } = await registerSessionRoutesAndGetHandler("GET", "/v1/sessions"); + const v1Route = v1App.routes.get("GET /v1/sessions"); + expect((v1Route?.opts as any)?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + + const { app: v2App } = await registerSessionRoutesAndGetHandler("GET", "/v2/sessions"); + const v2Route = v2App.routes.get("GET /v2/sessions"); + expect((v2Route?.opts as any)?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/session/sessionRoutes.messages.rateLimit.spec.ts b/apps/server/sources/app/api/routes/session/sessionRoutes.messages.rateLimit.spec.ts new file mode 100644 index 000000000..168e44cf5 --- /dev/null +++ b/apps/server/sources/app/api/routes/session/sessionRoutes.messages.rateLimit.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { registerSessionRoutesAndGetHandler } from "./sessionRoutes.testkit"; + +describe("sessionRoutes v1 messages rate limit", () => { + it("registers GET /v1/sessions/:sessionId/messages with an explicit rate limit", async () => { + const { app } = await registerSessionRoutesAndGetHandler("GET", "/v1/sessions/:sessionId/messages"); + const route = app.routes.get("GET /v1/sessions/:sessionId/messages"); + const rateLimit = (route?.opts as any)?.config?.rateLimit ?? null; + expect(rateLimit).toEqual( + expect.objectContaining({ + max: expect.any(Number), + timeWindow: expect.any(String), + }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/session/sessionRoutes.messagesByLocalId.rateLimit.spec.ts b/apps/server/sources/app/api/routes/session/sessionRoutes.messagesByLocalId.rateLimit.spec.ts new file mode 100644 index 000000000..81c13ba6d --- /dev/null +++ b/apps/server/sources/app/api/routes/session/sessionRoutes.messagesByLocalId.rateLimit.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { registerSessionRoutesAndGetHandler } from "./sessionRoutes.testkit"; + +describe("sessionRoutes v2 by-local-id rate limit", () => { + it("registers GET /v2/sessions/:sessionId/messages/by-local-id/:localId with an explicit rate limit", async () => { + const { app } = await registerSessionRoutesAndGetHandler("GET", "/v2/sessions/:sessionId/messages/by-local-id/:localId"); + const route = app.routes.get("GET /v2/sessions/:sessionId/messages/by-local-id/:localId"); + expect((route?.opts as any)?.config?.rateLimit).toEqual( + expect.objectContaining({ max: expect.any(Number), timeWindow: expect.any(String) }), + ); + }); +}); + diff --git a/apps/server/sources/app/api/routes/session/sessionRoutes.testkit.ts b/apps/server/sources/app/api/routes/session/sessionRoutes.testkit.ts index ca24d4ef9..d1660631e 100644 --- a/apps/server/sources/app/api/routes/session/sessionRoutes.testkit.ts +++ b/apps/server/sources/app/api/routes/session/sessionRoutes.testkit.ts @@ -42,6 +42,8 @@ export const sessionUpdate = vi.fn<(...args: any[]) => Promise<any>>(async () => throw new Error("sessionUpdate not configured for test"); }); export const sessionMessageFindMany = vi.fn<(...args: any[]) => Promise<any[]>>(async () => []); +export const sessionMessageFindFirst = vi.fn<(...args: any[]) => Promise<any | null>>(async () => null); +export const sessionMessageFindUnique = vi.fn<(...args: any[]) => Promise<any | null>>(async () => null); export const sessionShareFindMany = vi.fn<(...args: any[]) => Promise<any[]>>(async () => []); export const txSessionFindFirst = vi.fn<(...args: any[]) => Promise<any | null>>(async () => null); @@ -52,6 +54,7 @@ export const txSessionCreate = vi.fn<(...args: any[]) => Promise<any>>(async () export const txSessionUpdate = vi.fn<(...args: any[]) => Promise<any>>(async () => { throw new Error("txSessionUpdate not configured for test"); }); +export const txAccountFindUnique = vi.fn<(...args: any[]) => Promise<any | null>>(async () => null); export const catchupFetchesInc = vi.fn(); export const catchupReturnedInc = vi.fn(); @@ -97,6 +100,8 @@ vi.mock("@/storage/db", () => ({ sessionShare: { findMany: sessionShareFindMany }, sessionMessage: { findMany: sessionMessageFindMany, + findFirst: sessionMessageFindFirst, + findUnique: sessionMessageFindUnique, }, }, })); @@ -109,6 +114,9 @@ vi.mock("@/app/share/types", () => ({ PROFILE_SELECT: {}, toShareUserProfile: vi vi.mock("@/storage/inTx", () => ({ inTx: vi.fn(async (fn: any) => await fn({ + account: { + findUnique: txAccountFindUnique, + }, session: { create: txSessionCreate, findFirst: txSessionFindFirst, @@ -132,9 +140,12 @@ export function resetSessionRouteMocks(): void { throw new Error("sessionUpdate not configured for test"); }); sessionMessageFindMany.mockResolvedValue([]); + sessionMessageFindFirst.mockResolvedValue(null); + sessionMessageFindUnique.mockResolvedValue(null); sessionShareFindMany.mockResolvedValue([]); txSessionFindFirst.mockResolvedValue(null); txSessionFindUnique.mockResolvedValue(null); + txAccountFindUnique.mockResolvedValue({ encryptionMode: "e2ee" }); txSessionCreate.mockImplementation(async () => { throw new Error("txSessionCreate not configured for test"); }); diff --git a/apps/server/sources/app/api/routes/session/sessionRoutes.v1sessions.spec.ts b/apps/server/sources/app/api/routes/session/sessionRoutes.v1sessions.spec.ts index f06a791ec..e8cb7143e 100644 --- a/apps/server/sources/app/api/routes/session/sessionRoutes.v1sessions.spec.ts +++ b/apps/server/sources/app/api/routes/session/sessionRoutes.v1sessions.spec.ts @@ -7,6 +7,7 @@ import { sessionFindFirst, sessionFindMany, sessionShareFindMany, + txAccountFindUnique, txSessionFindFirst, txSessionCreate, } from "./sessionRoutes.testkit"; @@ -18,6 +19,8 @@ describe("sessionRoutes v1 sessions snapshot", () => { sessionShareFindMany.mockReset(); sessionFindFirst.mockReset(); txSessionFindFirst.mockReset(); + txAccountFindUnique.mockReset(); + txAccountFindUnique.mockResolvedValue({ encryptionMode: "e2ee" }); txSessionCreate.mockReset(); }); @@ -191,4 +194,156 @@ describe("sessionRoutes v1 sessions snapshot", () => { }), }); }); + + it("POST /v1/sessions forwards encryptionMode=plain when plaintext storage is optional", async () => { + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + + const now = new Date(1); + txSessionFindFirst.mockResolvedValue(null); + txSessionCreate.mockResolvedValue({ + id: "s2", + seq: 2, + createdAt: now, + updatedAt: now, + metadata: "m2", + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + dataEncryptionKey: null, + pendingCount: 0, + pendingVersion: 0, + active: true, + lastActiveAt: now, + encryptionMode: "plain", + }); + + const { handler } = await registerSessionRoutesAndGetHandler("POST", "/v1/sessions"); + const reply = createSessionRouteReply(); + + await handler( + { + userId: "u1", + body: { tag: "t2", metadata: "m2", agentState: null, dataEncryptionKey: null, encryptionMode: "plain" }, + }, + reply, + ); + + expect(txSessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + encryptionMode: "plain", + }), + }), + ); + }); + + it("POST /v1/sessions defaults encryptionMode to the account mode when not specified", async () => { + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + + const now = new Date(1); + txSessionFindFirst.mockResolvedValue(null); + txAccountFindUnique.mockResolvedValue({ encryptionMode: "plain" }); + txSessionCreate.mockResolvedValue({ + id: "s2", + seq: 2, + createdAt: now, + updatedAt: now, + metadata: "m2", + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + dataEncryptionKey: null, + pendingCount: 0, + pendingVersion: 0, + active: true, + lastActiveAt: now, + encryptionMode: "plain", + }); + + const { handler } = await registerSessionRoutesAndGetHandler("POST", "/v1/sessions"); + const reply = createSessionRouteReply(); + + await handler( + { + userId: "u1", + body: { tag: "t2", metadata: "m2", agentState: null, dataEncryptionKey: null }, + }, + reply, + ); + + expect(txSessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + encryptionMode: "plain", + }), + }), + ); + }); + + it("POST /v1/sessions stores agentState when provided", async () => { + const now = new Date(1); + txSessionFindFirst.mockResolvedValue(null); + txSessionCreate.mockResolvedValue({ + id: "s2", + seq: 2, + createdAt: now, + updatedAt: now, + metadata: "m2", + metadataVersion: 0, + agentState: "state-1", + agentStateVersion: 0, + dataEncryptionKey: null, + pendingCount: 0, + pendingVersion: 0, + active: true, + lastActiveAt: now, + encryptionMode: "e2ee", + }); + + const { handler } = await registerSessionRoutesAndGetHandler("POST", "/v1/sessions"); + const reply = createSessionRouteReply(); + + await handler( + { + userId: "u1", + body: { tag: "t2", metadata: "m2", agentState: "state-1", dataEncryptionKey: null }, + }, + reply, + ); + + expect(txSessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + agentState: "state-1", + }), + }), + ); + }); + + it("POST /v1/sessions returns a stable error code when the requested encryptionMode is disallowed by storage policy", async () => { + const prevStoragePolicy = process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "required_e2ee"; + + try { + const { handler } = await registerSessionRoutesAndGetHandler("POST", "/v1/sessions"); + const reply = createSessionRouteReply(); + + await handler( + { + userId: "u1", + body: { tag: "t1", metadata: "m1", agentState: null, dataEncryptionKey: null, encryptionMode: "plain" }, + }, + reply, + ); + + expect(reply.code).toHaveBeenCalledWith(400); + expect(reply.send).toHaveBeenCalledWith({ + error: "invalid-params", + code: "storage_policy_requires_e2ee", + }); + } finally { + if (typeof prevStoragePolicy === "string") process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = prevStoragePolicy; + else delete (process.env as any).HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + } + }); }); diff --git a/apps/server/sources/app/api/routes/session/sessionRoutes.v2archivedSessions.spec.ts b/apps/server/sources/app/api/routes/session/sessionRoutes.v2archivedSessions.spec.ts index 40a9109fd..073065180 100644 --- a/apps/server/sources/app/api/routes/session/sessionRoutes.v2archivedSessions.spec.ts +++ b/apps/server/sources/app/api/routes/session/sessionRoutes.v2archivedSessions.spec.ts @@ -20,6 +20,7 @@ describe("sessionRoutes v2 archived sessions listing", () => { id: "s2", seq: 2, accountId: "u1", + encryptionMode: "e2ee", createdAt: now, updatedAt: now, archivedAt: now, @@ -53,6 +54,7 @@ describe("sessionRoutes v2 archived sessions listing", () => { sessions: [ expect.objectContaining({ id: "s2", + encryptionMode: "e2ee", archivedAt: now.getTime(), }), ], @@ -61,4 +63,3 @@ describe("sessionRoutes v2 archived sessions listing", () => { }); }); }); - diff --git a/apps/server/sources/app/api/routes/session/sessionRoutes.v2messages.spec.ts b/apps/server/sources/app/api/routes/session/sessionRoutes.v2messages.spec.ts index b0d2109bf..681bb1710 100644 --- a/apps/server/sources/app/api/routes/session/sessionRoutes.v2messages.spec.ts +++ b/apps/server/sources/app/api/routes/session/sessionRoutes.v2messages.spec.ts @@ -14,6 +14,70 @@ describe("sessionRoutes v2 messages", () => { resetSessionRouteMocks(); }); + it("fetches a message by localId", async () => { + const createdAt = new Date("2020-01-01T00:00:00.000Z"); + const updatedAt = new Date("2020-01-01T00:00:01.000Z"); + const { sessionMessageFindUnique } = await import("./sessionRoutes.testkit"); + sessionMessageFindUnique.mockResolvedValueOnce({ + id: "m1", + seq: 10, + localId: "l1", + content: { t: "encrypted", c: "cipher" }, + createdAt, + updatedAt, + }); + + const { handler } = await registerSessionRoutesAndGetHandler("GET", "/v2/sessions/:sessionId/messages/by-local-id/:localId"); + const reply = createSessionRouteReply(); + + const res = await handler( + { + userId: "u1", + params: { sessionId: "s1", localId: "l1" }, + headers: {}, + query: {}, + }, + reply, + ); + + expect(sessionMessageFindUnique).toHaveBeenCalledWith({ + where: { sessionId_localId: { sessionId: "s1", localId: "l1" } }, + select: expect.any(Object), + }); + + expect(res).toEqual({ + message: { + id: "m1", + seq: 10, + localId: "l1", + content: { t: "encrypted", c: "cipher" }, + createdAt: createdAt.getTime(), + updatedAt: updatedAt.getTime(), + }, + }); + }); + + it("returns 404 when message localId is not found", async () => { + const { sessionMessageFindUnique } = await import("./sessionRoutes.testkit"); + sessionMessageFindUnique.mockResolvedValueOnce(null); + + const { handler } = await registerSessionRoutesAndGetHandler("GET", "/v2/sessions/:sessionId/messages/by-local-id/:localId"); + const reply = createSessionRouteReply(); + + await handler( + { + userId: "u1", + params: { sessionId: "s1", localId: "missing" }, + headers: {}, + query: {}, + }, + reply, + ); + + expect(reply.code).toHaveBeenCalledWith(404); + expect(reply.send).toHaveBeenCalledWith({ error: "Message not found" }); + }); + it("creates a message via service and emits updates using returned cursors", async () => { const createdAt = new Date("2020-01-01T00:00:00.000Z"); createSessionMessage.mockResolvedValue({ @@ -93,6 +157,41 @@ describe("sessionRoutes v2 messages", () => { }); }); + it("accepts plain content writes and forwards them to the service", async () => { + const createdAt = new Date(1); + createSessionMessage.mockResolvedValue({ + ok: true, + didWrite: true, + message: { id: "m1", seq: 10, localId: null, content: { t: "plain", v: { type: "user", text: "hi" } }, createdAt, updatedAt: createdAt }, + participantCursors: [{ accountId: "u1", cursor: 111 }], + }); + + const { handler } = await registerSessionRoutesAndGetHandler("POST", "/v2/sessions/:sessionId/messages"); + const reply = createSessionRouteReply(); + + const res = await handler( + { + userId: "u1", + params: { sessionId: "s1" }, + headers: {}, + body: { content: { t: "plain", v: { type: "user", text: "hi" } } }, + }, + reply, + ); + + expect(createSessionMessage).toHaveBeenCalledWith({ + actorUserId: "u1", + sessionId: "s1", + content: { t: "plain", v: { type: "user", text: "hi" } }, + localId: null, + }); + + expect(res).toEqual({ + didWrite: true, + message: { id: "m1", seq: 10, localId: null, createdAt: createdAt.getTime() }, + }); + }); + it("maps service errors to status codes", async () => { const { handler } = await registerSessionRoutesAndGetHandler("POST", "/v2/sessions/:sessionId/messages"); @@ -113,4 +212,25 @@ describe("sessionRoutes v2 messages", () => { await handler({ userId: "u1", params: { sessionId: "s1" }, headers: {}, body: { ciphertext: "x" } }, r3); expect(r3.code).toHaveBeenCalledWith(404); }); + + it("includes a stable error code when the service provides one for invalid-params", async () => { + const { handler } = await registerSessionRoutesAndGetHandler("POST", "/v2/sessions/:sessionId/messages"); + createSessionMessage.mockResolvedValueOnce({ + ok: false, + error: "invalid-params", + code: "session_encryption_mode_mismatch", + }); + + const reply = createSessionRouteReply(); + await handler( + { userId: "u1", params: { sessionId: "s1" }, headers: {}, body: { ciphertext: "x" } }, + reply, + ); + + expect(reply.code).toHaveBeenCalledWith(400); + expect(reply.send).toHaveBeenCalledWith({ + error: "Invalid parameters", + code: "session_encryption_mode_mismatch", + }); + }); }); diff --git a/apps/server/sources/app/api/routes/session/sessionRoutes.v2sessionById.spec.ts b/apps/server/sources/app/api/routes/session/sessionRoutes.v2sessionById.spec.ts index 8abdcfe55..98fafa507 100644 --- a/apps/server/sources/app/api/routes/session/sessionRoutes.v2sessionById.spec.ts +++ b/apps/server/sources/app/api/routes/session/sessionRoutes.v2sessionById.spec.ts @@ -19,6 +19,7 @@ describe("sessionRoutes v2 session by id", () => { id: "s1", seq: 1, accountId: "u1", + encryptionMode: "e2ee", createdAt: now, updatedAt: now, archivedAt: null, @@ -40,6 +41,7 @@ describe("sessionRoutes v2 session by id", () => { expect(res).toEqual({ session: expect.objectContaining({ id: "s1", + encryptionMode: "e2ee", dataEncryptionKey: "AQID", share: null, archivedAt: null, @@ -53,6 +55,7 @@ describe("sessionRoutes v2 session by id", () => { id: "s2", seq: 2, accountId: "owner", + encryptionMode: "e2ee", createdAt: now, updatedAt: now, archivedAt: null, @@ -80,6 +83,7 @@ describe("sessionRoutes v2 session by id", () => { expect(res).toEqual({ session: expect.objectContaining({ id: "s2", + encryptionMode: "e2ee", dataEncryptionKey: "BAU=", share: { accessLevel: "edit", canApprovePermissions: true }, archivedAt: null, diff --git a/apps/server/sources/app/api/routes/session/sessionRoutes.v2sessions.spec.ts b/apps/server/sources/app/api/routes/session/sessionRoutes.v2sessions.spec.ts index 83734d8a7..c5e385063 100644 --- a/apps/server/sources/app/api/routes/session/sessionRoutes.v2sessions.spec.ts +++ b/apps/server/sources/app/api/routes/session/sessionRoutes.v2sessions.spec.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { encodeV2SessionListCursorV1 } from "@happier-dev/protocol"; + import { createSessionRouteReply, registerSessionRoutesAndGetHandler, @@ -20,6 +22,7 @@ describe("sessionRoutes v2 sessions snapshot", () => { id: "s3", seq: 3, accountId: "u1", + encryptionMode: "e2ee", createdAt: now, updatedAt: now, archivedAt: null, @@ -36,6 +39,7 @@ describe("sessionRoutes v2 sessions snapshot", () => { id: "s2", seq: 2, accountId: "owner", + encryptionMode: "e2ee", createdAt: now, updatedAt: now, archivedAt: null, @@ -58,6 +62,7 @@ describe("sessionRoutes v2 sessions snapshot", () => { id: "s1", seq: 1, accountId: "u1", + encryptionMode: "plain", createdAt: now, updatedAt: now, archivedAt: null, @@ -87,18 +92,20 @@ describe("sessionRoutes v2 sessions snapshot", () => { sessions: [ expect.objectContaining({ id: "s3", + encryptionMode: "e2ee", dataEncryptionKey: "AQID", share: null, archivedAt: null, }), expect.objectContaining({ id: "s2", + encryptionMode: "e2ee", dataEncryptionKey: "BAU=", share: { accessLevel: "edit", canApprovePermissions: true }, archivedAt: null, }), ], - nextCursor: "cursor_v1_s2", + nextCursor: encodeV2SessionListCursorV1("s2"), hasNext: true, }); }); diff --git a/apps/server/sources/app/api/routes/share/publicShareRoutes.changes.integration.spec.ts b/apps/server/sources/app/api/routes/share/publicShareRoutes.changes.integration.spec.ts index 8f8cfd0c2..c6975d84b 100644 --- a/apps/server/sources/app/api/routes/share/publicShareRoutes.changes.integration.spec.ts +++ b/apps/server/sources/app/api/routes/share/publicShareRoutes.changes.integration.spec.ts @@ -44,9 +44,13 @@ let txFindUnique: any; let txCreate: any; let txUpdate: any; let txDelete: any; +let txSessionFindUnique: any; vi.mock("@/storage/inTx", () => { const harness = createInTxHarness(() => ({ + session: { + findUnique: (...args: any[]) => txSessionFindUnique(...args), + }, publicSessionShare: { findUnique: (...args: any[]) => txFindUnique(...args), create: (...args: any[]) => txCreate(...args), @@ -66,6 +70,7 @@ describe("publicShareRoutes (AccountChange integration)", () => { txCreate = vi.fn(); txUpdate = vi.fn(); txDelete = vi.fn(); + txSessionFindUnique = vi.fn(async () => ({ encryptionMode: "e2ee" })); }); it("POST create marks share+session and emits created update using latest cursor", async () => { diff --git a/apps/server/sources/app/api/routes/share/publicShareRoutes.optionalAuth.spec.ts b/apps/server/sources/app/api/routes/share/publicShareRoutes.optionalAuth.spec.ts index 4b9f26107..3afd9df7c 100644 --- a/apps/server/sources/app/api/routes/share/publicShareRoutes.optionalAuth.spec.ts +++ b/apps/server/sources/app/api/routes/share/publicShareRoutes.optionalAuth.spec.ts @@ -154,6 +154,11 @@ describe("publicShareRoutes optional auth (no reply-already-sent)", () => { useCount: 0, isConsentRequired: false, blockedUsers: undefined, + encryptedDataKey: new Uint8Array([1, 2, 3]), + })); + + sessionFindUnique.mockImplementation(async () => ({ + encryptionMode: "e2ee", })); sessionMessageFindMany.mockImplementation(async () => [ @@ -178,4 +183,3 @@ describe("publicShareRoutes optional auth (no reply-already-sent)", () => { expect(payload).toEqual({ messages: [{ id: "m1", seq: 1, content: "c", localId: "l1", createdAt: 1, updatedAt: 2 }] }); }); }); - diff --git a/apps/server/sources/app/api/routes/share/publicShareRoutes.plaintext.integration.spec.ts b/apps/server/sources/app/api/routes/share/publicShareRoutes.plaintext.integration.spec.ts new file mode 100644 index 000000000..ac2629284 --- /dev/null +++ b/apps/server/sources/app/api/routes/share/publicShareRoutes.plaintext.integration.spec.ts @@ -0,0 +1,155 @@ +import Fastify from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod"; +import { createHash } from "crypto"; + +import { applyLightDefaultEnv, ensureHandyMasterSecret } from "@/flavors/light/env"; +import { initDbSqlite, db } from "@/storage/db"; +import { publicShareRoutes } from "./publicShareRoutes"; + +function runServerPrismaMigrateDeploySqlite(params: { cwd: string; env: NodeJS.ProcessEnv }): void { + const res = spawnSync( + "yarn", + ["-s", "prisma", "migrate", "deploy", "--schema", "prisma/sqlite/schema.prisma"], + { + cwd: params.cwd, + env: { ...(params.env as Record<string, string>), RUST_LOG: "info" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (res.status !== 0) { + const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`.trim(); + throw new Error(`prisma migrate deploy failed (status=${res.status}). ${out}`); + } +} + +function createAuthenticatedTestApp() { + const app = Fastify({ logger: false }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + const typed = app.withTypeProvider<ZodTypeProvider>() as any; + typed.decorate("authenticate", async (request: any, reply: any) => { + const userId = request.headers["x-test-user-id"]; + if (typeof userId !== "string" || !userId) { + return reply.code(401).send({ error: "Unauthorized" }); + } + request.userId = userId; + }); + return typed; +} + +describe("publicShareRoutes plaintext sessions (integration)", () => { + const envBackup = { ...process.env }; + let baseDir: string; + + beforeAll(async () => { + baseDir = await mkdtemp(join(tmpdir(), "happier-public-share-plain-")); + const dbPath = join(baseDir, "test.sqlite"); + + process.env = { + ...process.env, + HAPPIER_DB_PROVIDER: "sqlite", + HAPPY_DB_PROVIDER: "sqlite", + DATABASE_URL: `file:${dbPath}`, + HAPPY_SERVER_LIGHT_DATA_DIR: baseDir, + }; + applyLightDefaultEnv(process.env); + await ensureHandyMasterSecret(process.env); + + runServerPrismaMigrateDeploySqlite({ cwd: process.cwd(), env: process.env }); + await initDbSqlite(); + await db.$connect(); + }); + + afterAll(async () => { + process.env = envBackup; + await db.$disconnect(); + await rm(baseDir, { recursive: true, force: true }); + }); + + it("creates and accesses a public share for a plaintext session without encryptedDataKey", async () => { + const owner = await db.account.create({ data: { publicKey: "pk_owner" }, select: { id: true } }); + const session = await db.session.create({ + data: { + accountId: owner.id, + tag: "s_plain", + encryptionMode: "plain", + metadata: JSON.stringify({ v: 1, flavor: "claude" }), + agentState: null, + dataEncryptionKey: null, + }, + select: { id: true }, + }); + + const app = createAuthenticatedTestApp(); + publicShareRoutes(app as any); + await app.ready(); + try { + const token = "tok_plain_1"; + const createRes = await app.inject({ + method: "POST", + url: `/v1/sessions/${session.id}/public-share`, + headers: { "x-test-user-id": owner.id, "content-type": "application/json" }, + payload: JSON.stringify({ token, isConsentRequired: false }), + }); + expect(createRes.statusCode).toBe(200); + + const accessRes = await app.inject({ + method: "GET", + url: `/v1/public-share/${encodeURIComponent(token)}`, + }); + expect(accessRes.statusCode).toBe(200); + const json = accessRes.json(); + expect(json.session?.id).toBe(session.id); + expect(json.session?.encryptionMode).toBe("plain"); + expect(json.encryptedDataKey).toBe(null); + } finally { + await app.close(); + } + }); + + it("returns 404 for message reads when an E2EE session public share is missing encryptedDataKey", async () => { + const owner = await db.account.create({ data: { publicKey: "pk_owner_2" }, select: { id: true } }); + const session = await db.session.create({ + data: { + accountId: owner.id, + tag: "s_e2ee", + encryptionMode: "e2ee", + metadata: "ciphertext", + agentState: null, + dataEncryptionKey: Buffer.from([1, 2, 3]), + }, + select: { id: true }, + }); + + const token = "tok_e2ee_missing_dek"; + const tokenHash = createHash("sha256").update(token, "utf8").digest(); + await db.publicSessionShare.create({ + data: { + sessionId: session.id, + createdByUserId: owner.id, + tokenHash, + encryptedDataKey: null, + isConsentRequired: false, + }, + }); + + const app = createAuthenticatedTestApp(); + publicShareRoutes(app as any); + await app.ready(); + try { + const messagesRes = await app.inject({ + method: "GET", + url: `/v1/public-share/${encodeURIComponent(token)}/messages`, + }); + expect(messagesRes.statusCode).toBe(404); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/server/sources/app/api/routes/share/registerPublicShareOwnerRoutes.ts b/apps/server/sources/app/api/routes/share/registerPublicShareOwnerRoutes.ts index 7467063d6..3b1281e99 100644 --- a/apps/server/sources/app/api/routes/share/registerPublicShareOwnerRoutes.ts +++ b/apps/server/sources/app/api/routes/share/registerPublicShareOwnerRoutes.ts @@ -12,6 +12,7 @@ import { import { createHash } from "crypto"; import { afterTx, inTx } from "@/storage/inTx"; import { markAccountChanged } from "@/app/changes/markAccountChanged"; +import { gateRateLimitConfig } from "@/app/api/utils/apiRateLimitPolicy"; export function registerPublicShareOwnerRoutes(app: Fastify): void { /** @@ -20,10 +21,10 @@ export function registerPublicShareOwnerRoutes(app: Fastify): void { app.post('/v1/sessions/:sessionId/public-share', { preHandler: app.authenticate, config: { - rateLimit: { + rateLimit: gateRateLimitConfig(process.env, { max: 10, timeWindow: '1 minute' - } + }) }, schema: { params: z.object({ @@ -48,6 +49,15 @@ export function registerPublicShareOwnerRoutes(app: Fastify): void { } const result = await inTx(async (tx) => { + const session = await tx.session.findUnique({ + where: { id: sessionId }, + select: { encryptionMode: true }, + }); + if (!session) { + return { type: 'error' as const, error: 'session not found' as const }; + } + const sessionEncryptionMode: "e2ee" | "plain" = session.encryptionMode === "plain" ? "plain" : "e2ee"; + const existing = await tx.publicSessionShare.findUnique({ where: { sessionId } }); @@ -57,7 +67,7 @@ export function registerPublicShareOwnerRoutes(app: Fastify): void { if (existing) { const shouldRotateToken = typeof token === 'string' && token.length > 0; - if (shouldRotateToken && !encryptedDataKey) { + if (shouldRotateToken && sessionEncryptionMode === "e2ee" && !encryptedDataKey) { return { type: 'error' as const, error: 'encryptedDataKey required when rotating token' as const }; } const nextTokenHash = shouldRotateToken ? createHash('sha256').update(token!, 'utf8').digest() : null; @@ -66,7 +76,11 @@ export function registerPublicShareOwnerRoutes(app: Fastify): void { where: { sessionId }, data: { ...(nextTokenHash ? { tokenHash: nextTokenHash } : {}), - ...(encryptedDataKey ? { encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')) } : {}), + ...(sessionEncryptionMode === "plain" + ? { encryptedDataKey: null } + : encryptedDataKey + ? { encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')) } + : {}), expiresAt: expiresAt ? new Date(expiresAt) : null, maxUses: maxUses ?? null, isConsentRequired: isConsentRequired ?? false, @@ -77,7 +91,7 @@ export function registerPublicShareOwnerRoutes(app: Fastify): void { if (!token) { return { type: 'error' as const, error: 'token required' as const }; } - if (!encryptedDataKey) { + if (sessionEncryptionMode === "e2ee" && !encryptedDataKey) { return { type: 'error' as const, error: 'encryptedDataKey required' as const }; } const tokenHash = createHash('sha256').update(token, 'utf8').digest(); @@ -87,7 +101,10 @@ export function registerPublicShareOwnerRoutes(app: Fastify): void { sessionId, createdByUserId: userId, tokenHash, - encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')), + encryptedDataKey: + sessionEncryptionMode === "plain" + ? null + : new Uint8Array(Buffer.from(encryptedDataKey!, 'base64')), expiresAt: expiresAt ? new Date(expiresAt) : null, maxUses: maxUses ?? null, isConsentRequired: isConsentRequired ?? false diff --git a/apps/server/sources/app/api/routes/share/registerPublicShareReadRoutes.ts b/apps/server/sources/app/api/routes/share/registerPublicShareReadRoutes.ts index 3d4cb086c..5f9c3755e 100644 --- a/apps/server/sources/app/api/routes/share/registerPublicShareReadRoutes.ts +++ b/apps/server/sources/app/api/routes/share/registerPublicShareReadRoutes.ts @@ -5,6 +5,7 @@ import { logPublicShareAccess, getIpAddress, getUserAgent } from "@/app/share/ac import { PROFILE_SELECT, toShareUserProfile } from "@/app/share/types"; import { createHash } from "crypto"; import { auth } from "@/app/auth/auth"; +import { gateRateLimitConfig } from "@/app/api/utils/apiRateLimitPolicy"; async function getOptionalAuthenticatedUserId(request: any): Promise<string | null> { const authHeader = request?.headers?.authorization; @@ -29,10 +30,10 @@ export function registerPublicShareReadRoutes(app: Fastify): void { */ app.get('/v1/public-share/:token', { config: { - rateLimit: { + rateLimit: gateRateLimitConfig(process.env, { max: 10, timeWindow: '1 minute' - } + }) }, schema: { params: z.object({ @@ -149,6 +150,7 @@ export function registerPublicShareReadRoutes(app: Fastify): void { select: { id: true, seq: true, + encryptionMode: true, createdAt: true, updatedAt: true, metadata: true, @@ -167,10 +169,22 @@ export function registerPublicShareReadRoutes(app: Fastify): void { return reply.code(404).send({ error: 'Session not found' }); } + const sessionEncryptionMode: "e2ee" | "plain" = session.encryptionMode === "plain" ? "plain" : "e2ee"; + const encryptedDataKeyB64 = + sessionEncryptionMode === "plain" + ? null + : result.encryptedDataKey + ? Buffer.from(result.encryptedDataKey).toString("base64") + : null; + if (sessionEncryptionMode === "e2ee" && !encryptedDataKeyB64) { + return reply.code(404).send({ error: "Public share not found or expired" }); + } + return reply.send({ session: { id: session.id, seq: session.seq, + encryptionMode: sessionEncryptionMode, createdAt: session.createdAt.getTime(), updatedAt: session.updatedAt.getTime(), active: session.active, @@ -182,7 +196,7 @@ export function registerPublicShareReadRoutes(app: Fastify): void { }, owner: toShareUserProfile(session.account), accessLevel: 'view', - encryptedDataKey: Buffer.from(result.encryptedDataKey).toString('base64'), + encryptedDataKey: encryptedDataKeyB64, isConsentRequired: result.isConsentRequired }); }); @@ -194,10 +208,10 @@ export function registerPublicShareReadRoutes(app: Fastify): void { */ app.get('/v1/public-share/:token/messages', { config: { - rateLimit: { + rateLimit: gateRateLimitConfig(process.env, { max: 20, timeWindow: '1 minute' - } + }) }, schema: { params: z.object({ @@ -225,6 +239,7 @@ export function registerPublicShareReadRoutes(app: Fastify): void { maxUses: true, useCount: true, isConsentRequired: true, + encryptedDataKey: true, blockedUsers: userId ? { where: { userId }, select: { id: true } @@ -270,6 +285,18 @@ export function registerPublicShareReadRoutes(app: Fastify): void { }); } + const session = await db.session.findUnique({ + where: { id: publicShare.sessionId }, + select: { encryptionMode: true }, + }); + if (!session) { + return reply.code(404).send({ error: 'Public share not found or expired' }); + } + const sessionEncryptionMode: "e2ee" | "plain" = session.encryptionMode === "plain" ? "plain" : "e2ee"; + if (sessionEncryptionMode === "e2ee" && !publicShare.encryptedDataKey) { + return reply.code(404).send({ error: "Public share not found or expired" }); + } + const messages = await db.sessionMessage.findMany({ where: { sessionId: publicShare.sessionId }, orderBy: { createdAt: 'desc' }, diff --git a/apps/server/sources/app/api/routes/share/shareRoutes.changes.spec.ts b/apps/server/sources/app/api/routes/share/shareRoutes.changes.spec.ts index c0e3c3492..151b3b130 100644 --- a/apps/server/sources/app/api/routes/share/shareRoutes.changes.spec.ts +++ b/apps/server/sources/app/api/routes/share/shareRoutes.changes.spec.ts @@ -95,7 +95,7 @@ const ENCRYPTED_DATA_KEY = Buffer.from(Uint8Array.from([0, ...new Array(73).fill describe("shareRoutes (AccountChange integration)", () => { beforeEach(() => { vi.clearAllMocks(); - dbSessionFindUnique.mockResolvedValue({ id: "s1" }); + dbSessionFindUnique.mockResolvedValue({ id: "s1", encryptionMode: "e2ee" }); dbAccountFindUnique.mockResolvedValue({ id: "recipient" }); txSessionShareUpsert = vi.fn(async () => ({ @@ -164,6 +164,49 @@ describe("shareRoutes (AccountChange integration)", () => { ); }); + it("POST allows plaintext sessions without an encryptedDataKey", async () => { + dbSessionFindUnique.mockResolvedValue({ id: "s1", encryptionMode: "plain" }); + txSessionShareUpsert = vi.fn(async (args: any) => ({ + id: "share-1", + sessionId: "s1", + sharedWithUserId: "recipient", + accessLevel: "edit", + canApprovePermissions: false, + encryptedDataKey: null, + sharedWithUser: { id: "recipient" }, + sharedByUser: { id: "owner" }, + createdAt: new Date(1), + updatedAt: new Date(1), + })); + + const { shareRoutes } = await import("./shareRoutes"); + const app = createFakeRouteApp(); + shareRoutes(app as any); + + const handler = getRouteHandler(app, "POST", "/v1/sessions/:sessionId/shares"); + const reply = createReplyStub(); + + await handler( + { + userId: "owner", + params: { sessionId: "s1" }, + body: { + userId: "recipient", + accessLevel: "edit", + }, + }, + reply, + ); + + expect(reply.code).not.toHaveBeenCalledWith(400); + expect(txSessionShareUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ encryptedDataKey: null }), + update: expect.objectContaining({ encryptedDataKey: null }), + }), + ); + }); + it("PATCH marks owner+recipient share changes (and recipient session) and emits using latest recipient cursor", async () => { const { shareRoutes } = await import("./shareRoutes"); const app = createFakeRouteApp(); diff --git a/apps/server/sources/app/api/routes/share/shareRoutes.ts b/apps/server/sources/app/api/routes/share/shareRoutes.ts index e7c5b8a85..b3509e9f6 100644 --- a/apps/server/sources/app/api/routes/share/shareRoutes.ts +++ b/apps/server/sources/app/api/routes/share/shareRoutes.ts @@ -8,6 +8,7 @@ import { eventRouter, buildSessionSharedUpdate, buildSessionShareUpdatedUpdate, import { randomKeyNaked } from "@/utils/keys/randomKeyNaked"; import { afterTx, inTx } from "@/storage/inTx"; import { markAccountChanged } from "@/app/changes/markAccountChanged"; +import { gateRateLimitConfig } from "@/app/api/utils/apiRateLimitPolicy"; type SessionShareRow = Awaited<ReturnType<typeof db.sessionShare.findFirst>>; @@ -81,10 +82,10 @@ export function shareRoutes(app: Fastify) { app.post('/v1/sessions/:sessionId/shares', { preHandler: app.authenticate, config: { - rateLimit: { + rateLimit: gateRateLimitConfig(process.env, { max: 20, timeWindow: '1 minute' - } + }) }, schema: { params: z.object({ @@ -94,7 +95,7 @@ export function shareRoutes(app: Fastify) { userId: z.string(), accessLevel: z.enum(['view', 'edit', 'admin']), canApprovePermissions: z.boolean().optional(), - encryptedDataKey: z.string(), + encryptedDataKey: z.string().optional(), }) } }, async (request, reply) => { @@ -104,11 +105,12 @@ export function shareRoutes(app: Fastify) { const session = await db.session.findUnique({ where: { id: sessionId }, - select: { id: true } + select: { id: true, encryptionMode: true } }); if (!session) { return reply.code(404).send({ error: 'Session not found' }); } + const sessionEncryptionMode: "e2ee" | "plain" = session.encryptionMode === "plain" ? "plain" : "e2ee"; // Only owner or admin can create shares if (!await canManageSharing(ownerId, sessionId)) { @@ -144,11 +146,16 @@ export function shareRoutes(app: Fastify) { return reply.code(403).send({ error: 'Can only share with friends' }); } - let encryptedDataKeyBytes: Uint8Array<ArrayBuffer>; - try { - encryptedDataKeyBytes = parseEncryptedDataKeyV0(encryptedDataKey); - } catch (error) { - return reply.code(400).send({ error: 'Invalid encryptedDataKey' }); + let encryptedDataKeyBytes: Uint8Array<ArrayBuffer> | null = null; + if (sessionEncryptionMode === "e2ee") { + if (typeof encryptedDataKey !== "string" || encryptedDataKey.length === 0) { + return reply.code(400).send({ error: "encryptedDataKey required" }); + } + try { + encryptedDataKeyBytes = parseEncryptedDataKeyV0(encryptedDataKey); + } catch { + return reply.code(400).send({ error: 'Invalid encryptedDataKey' }); + } } const share = await inTx(async (tx) => { diff --git a/apps/server/sources/app/api/routes/voice/registerVoiceMintRoute.ts b/apps/server/sources/app/api/routes/voice/registerVoiceMintRoute.ts index 1cfd0f5f2..252547704 100644 --- a/apps/server/sources/app/api/routes/voice/registerVoiceMintRoute.ts +++ b/apps/server/sources/app/api/routes/voice/registerVoiceMintRoute.ts @@ -5,6 +5,7 @@ import { parseIntEnv } from "@/config/env"; import { resolveElevenLabsAgentId, resolveElevenLabsApiBaseUrl } from "@/voice/elevenLabsEnv"; import { readVoiceFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; import { resolveServerFeaturesForGating } from "@/app/features/catalog/serverFeatureGate"; +import { createApiRateLimitKeyGenerator, gateRateLimitConfig } from "@/app/api/utils/apiRateLimitPolicy"; import { type Fastify } from "../../types"; type VoiceDenyReason = @@ -34,17 +35,17 @@ export function registerVoiceMintRoute(app: Fastify, path: "/v1/voice/token" | " const maxPerMinute = Math.max(0, parseIntEnv(process.env.VOICE_TOKEN_MAX_PER_MINUTE, 10)); return { rateLimit: - maxPerMinute <= 0 - ? false - : { + gateRateLimitConfig( + process.env, + maxPerMinute <= 0 + ? false + : { max: maxPerMinute, timeWindow: "1 minute", - // Rate limit per authenticated user when possible. - keyGenerator: (req: any) => - req && typeof (req as any).userId === "string" - ? (req as any).userId - : req.ip, + // Prefer Authorization-header-derived keys to avoid proxy/IP misconfig surprises. + keyGenerator: createApiRateLimitKeyGenerator(), }, + ), }; })(), schema: { @@ -251,56 +252,58 @@ export function registerVoiceMintRoute(app: Fastify, path: "/v1/voice/token" | " grantedBy = "free"; } - // Persist a session lease before minting to close race windows. let leaseId: string | null = null; + // Persist the session lease + enforce concurrency/quota within the same transaction to + // avoid TOCTOU windows under concurrent requests (especially on sqlite). try { - const lease = await db.voiceSessionLease.create({ - data: { - accountId: userId, - sessionId, - periodKey, - grantedBy, - elevenLabsAgentId, - expiresAt, - }, - select: { id: true }, - }); - leaseId = lease.id; - } catch (e) { - log({ module: "voice" }, "Failed to create voice session lease", e); - return reply.code(503).send({ allowed: false, reason: "upstream_error" satisfies VoiceDenyReason }); - } - - // Enforce concurrency/quota after creating a lease to close TOCTOU race windows under concurrent requests. - try { - const activeWinners = await db.voiceSessionLease.findMany({ - where: { accountId: userId, expiresAt: { gt: now }, conversation: null }, - orderBy: [{ createdAt: "asc" }, { id: "asc" }], - take: maxConcurrentSessions, - select: { id: true }, - }); - const isWithinConcurrency = activeWinners.some((l) => l.id === leaseId); - if (!isWithinConcurrency) { - await db.voiceSessionLease.delete({ where: { id: leaseId } }).catch(() => {}); - return reply.code(429).send({ allowed: false, reason: "too_many_sessions" satisfies VoiceDenyReason }); - } + const result = await db.$transaction(async (tx) => { + const lease = await tx.voiceSessionLease.create({ + data: { + accountId: userId, + sessionId, + periodKey, + grantedBy, + elevenLabsAgentId, + expiresAt, + }, + select: { id: true }, + }); - if (requireSubscription && grantedBy === "free" && freeSessionsPerMonth > 0) { - const quotaWinners = await db.voiceSessionLease.findMany({ - where: { accountId: userId, periodKey, grantedBy: "free" }, + const activeWinners = await tx.voiceSessionLease.findMany({ + where: { accountId: userId, expiresAt: { gt: now }, conversation: null }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], - take: freeSessionsPerMonth, + take: maxConcurrentSessions, select: { id: true }, }); - const isWithinQuota = quotaWinners.some((l) => l.id === leaseId); - if (!isWithinQuota) { - await db.voiceSessionLease.delete({ where: { id: leaseId } }).catch(() => {}); - return reply.code(403).send({ allowed: false, reason: "quota_exceeded" satisfies VoiceDenyReason }); + const isWithinConcurrency = activeWinners.some((l) => l.id === lease.id); + if (!isWithinConcurrency) { + await tx.voiceSessionLease.delete({ where: { id: lease.id } }).catch(() => {}); + return { ok: false as const, statusCode: 429 as const, reason: "too_many_sessions" satisfies VoiceDenyReason }; + } + + if (requireSubscription && grantedBy === "free" && freeSessionsPerMonth > 0) { + const quotaWinners = await tx.voiceSessionLease.findMany({ + where: { accountId: userId, periodKey, grantedBy: "free" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: freeSessionsPerMonth, + select: { id: true }, + }); + const isWithinQuota = quotaWinners.some((l) => l.id === lease.id); + if (!isWithinQuota) { + await tx.voiceSessionLease.delete({ where: { id: lease.id } }).catch(() => {}); + return { ok: false as const, statusCode: 403 as const, reason: "quota_exceeded" satisfies VoiceDenyReason }; + } } + + return { ok: true as const, leaseId: lease.id }; + }); + + if (!result.ok) { + return reply.code(result.statusCode).send({ allowed: false, reason: result.reason }); } + leaseId = result.leaseId; } catch (e) { - log({ module: "voice" }, "Failed to enforce voice concurrency/quota", e); - await db.voiceSessionLease.delete({ where: { id: leaseId } }).catch(() => {}); + log({ module: "voice" }, "Failed to create/enforce voice session lease", e); return reply.code(503).send({ allowed: false, reason: "upstream_error" satisfies VoiceDenyReason }); } diff --git a/apps/server/sources/app/api/routes/voice/registerVoiceSessionCompleteRoute.ts b/apps/server/sources/app/api/routes/voice/registerVoiceSessionCompleteRoute.ts index 23bb07702..dc92ada54 100644 --- a/apps/server/sources/app/api/routes/voice/registerVoiceSessionCompleteRoute.ts +++ b/apps/server/sources/app/api/routes/voice/registerVoiceSessionCompleteRoute.ts @@ -4,6 +4,7 @@ import { db } from "@/storage/db"; import { parseIntEnv } from "@/config/env"; import { resolveElevenLabsApiBaseUrl } from "@/voice/elevenLabsEnv"; import { resolveServerFeaturesForGating } from "@/app/features/catalog/serverFeatureGate"; +import { createApiRateLimitKeyGenerator, gateRateLimitConfig } from "@/app/api/utils/apiRateLimitPolicy"; import { type Fastify } from "../../types"; function extractConversationAgentId(payload: any): string | null { @@ -30,16 +31,16 @@ export function registerVoiceSessionCompleteRoute(app: Fastify): void { const maxPerMinute = Math.max(0, parseIntEnv(process.env.VOICE_COMPLETE_MAX_PER_MINUTE, 60)); return { rateLimit: - maxPerMinute <= 0 - ? false - : { + gateRateLimitConfig( + process.env, + maxPerMinute <= 0 + ? false + : { max: maxPerMinute, timeWindow: "1 minute", - keyGenerator: (req: any) => - req && typeof (req as any).userId === "string" - ? (req as any).userId - : req.ip, + keyGenerator: createApiRateLimitKeyGenerator(), }, + ), }; })(), schema: { diff --git a/apps/server/sources/app/api/routes/voice/voiceRoutes.feat.voice.rateLimit.spec.ts b/apps/server/sources/app/api/routes/voice/voiceRoutes.feat.voice.rateLimit.spec.ts index 577cae8af..f93f3af44 100644 --- a/apps/server/sources/app/api/routes/voice/voiceRoutes.feat.voice.rateLimit.spec.ts +++ b/apps/server/sources/app/api/routes/voice/voiceRoutes.feat.voice.rateLimit.spec.ts @@ -56,6 +56,7 @@ describe("voiceRoutes (rate limit)", () => { }), ); expect(opts?.config?.rateLimit?.keyGenerator).toEqual(expect.any(Function)); + expect(opts?.config?.rateLimit?.keyGenerator?.({ headers: { authorization: "Bearer token_1" }, ip: "203.0.113.9" })).toMatch(/^auth:/); }); it("registers /v1/voice/session/complete with a per-user rate limit by default", async () => { @@ -71,5 +72,6 @@ describe("voiceRoutes (rate limit)", () => { }), ); expect(opts?.config?.rateLimit?.keyGenerator).toEqual(expect.any(Function)); + expect(opts?.config?.rateLimit?.keyGenerator?.({ headers: { authorization: "Bearer token_1" }, ip: "203.0.113.9" })).toMatch(/^auth:/); }); }); diff --git a/apps/server/sources/app/api/routes/voice/voiceRoutes.feat.voice.secure.spec.ts b/apps/server/sources/app/api/routes/voice/voiceRoutes.feat.voice.secure.spec.ts index b05c92a57..152ce27c8 100644 --- a/apps/server/sources/app/api/routes/voice/voiceRoutes.feat.voice.secure.spec.ts +++ b/apps/server/sources/app/api/routes/voice/voiceRoutes.feat.voice.secure.spec.ts @@ -11,6 +11,18 @@ const conversationAggregate = vi.fn(); vi.mock("@/storage/db", () => ({ db: { + $transaction: async (fn: any) => fn({ + voiceSessionLease: { + count: (...args: any[]) => leaseCount(...args), + create: (...args: any[]) => leaseCreate(...args), + findMany: (...args: any[]) => leaseFindMany(...args), + delete: (...args: any[]) => leaseDelete(...args), + deleteMany: (...args: any[]) => leaseDeleteMany(...args), + }, + voiceConversation: { + aggregate: (...args: any[]) => conversationAggregate(...args), + }, + }), voiceSessionLease: { count: (...args: any[]) => leaseCount(...args), create: (...args: any[]) => leaseCreate(...args), diff --git a/apps/server/sources/app/api/socket/sessionUpdateHandler.changes.integration.spec.ts b/apps/server/sources/app/api/socket/sessionUpdateHandler.changes.integration.spec.ts index f6172e35f..5ce1993fe 100644 --- a/apps/server/sources/app/api/socket/sessionUpdateHandler.changes.integration.spec.ts +++ b/apps/server/sources/app/api/socket/sessionUpdateHandler.changes.integration.spec.ts @@ -85,6 +85,7 @@ vi.mock("@/storage/inTx", () => { update: vi.fn(async () => ({ seq: 55 })), }, sessionMessage: { + findUnique: vi.fn(async () => null), findFirst: vi.fn(async () => null), create: vi.fn(async () => ({ id: "m1", diff --git a/apps/server/sources/app/api/socket/sessionUpdateHandler.spec.ts b/apps/server/sources/app/api/socket/sessionUpdateHandler.spec.ts index 03d68ca7d..89ee566a4 100644 --- a/apps/server/sources/app/api/socket/sessionUpdateHandler.spec.ts +++ b/apps/server/sources/app/api/socket/sessionUpdateHandler.spec.ts @@ -1,11 +1,23 @@ -import { describe, expect, it, vi } from "vitest"; -import { sessionUpdateHandler } from "./sessionUpdateHandler"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createFakeSocket, getSocketHandler } from "../testkit/socketHarness"; +const createSessionMessage = vi.fn(async () => ({ ok: false, error: "invalid-params" })); +vi.mock("@/app/session/sessionWriteService", () => ({ + createSessionMessage, + updateSessionMetadata: vi.fn(async () => ({ ok: false, error: "internal" })), + updateSessionAgentState: vi.fn(async () => ({ ok: false, error: "internal" })), +})); +vi.mock("@/utils/logging/log", () => ({ log: vi.fn() })); + describe("sessionUpdateHandler", () => { + beforeEach(() => { + createSessionMessage.mockClear(); + }); + it("does not crash on invalid message payloads and acks with invalid-params when callback is provided", async () => { const socket = createFakeSocket(); + const { sessionUpdateHandler } = await import("./sessionUpdateHandler"); sessionUpdateHandler( "user-1", socket as any, @@ -29,6 +41,7 @@ describe("sessionUpdateHandler", () => { it("does not crash on invalid message payloads when callback is missing (old clients)", async () => { const socket = createFakeSocket(); + const { sessionUpdateHandler } = await import("./sessionUpdateHandler"); sessionUpdateHandler( "user-1", socket as any, @@ -39,4 +52,53 @@ describe("sessionUpdateHandler", () => { await expect(handler({ sid: "s-1" })).resolves.toBeUndefined(); }); + + it("accepts plain message envelopes and forwards them to createSessionMessage", async () => { + const socket = createFakeSocket(); + + const { sessionUpdateHandler } = await import("./sessionUpdateHandler"); + sessionUpdateHandler( + "user-1", + socket as any, + { connectionType: "session-scoped", socket: socket as any, userId: "user-1", sessionId: "s-1" } as any, + ); + + const handler = getSocketHandler(socket, "message"); + const callback = vi.fn(); + await handler({ sid: "s-1", message: { t: "plain", v: { type: "user", text: "hi" } } }, callback); + + expect(createSessionMessage).toHaveBeenCalledWith({ + actorUserId: "user-1", + sessionId: "s-1", + content: { t: "plain", v: { type: "user", text: "hi" } }, + localId: null, + }); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ ok: false, error: "invalid-params" })); + }); + + it("does not crash when plain message envelopes contain unserializable payloads", async () => { + const socket = createFakeSocket(); + + const { sessionUpdateHandler } = await import("./sessionUpdateHandler"); + sessionUpdateHandler( + "user-1", + socket as any, + { connectionType: "session-scoped", socket: socket as any, userId: "user-1", sessionId: "s-1" } as any, + ); + + const circular: any = { kind: "circular" }; + circular.self = circular; + + const handler = getSocketHandler(socket, "message"); + const callback = vi.fn(); + await handler({ sid: "s-1", message: { t: "plain", v: circular } }, callback); + + expect(createSessionMessage).toHaveBeenCalledWith({ + actorUserId: "user-1", + sessionId: "s-1", + content: { t: "plain", v: circular }, + localId: null, + }); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ ok: false, error: "invalid-params" })); + }); }); diff --git a/apps/server/sources/app/api/socket/sessionUpdateHandler.ts b/apps/server/sources/app/api/socket/sessionUpdateHandler.ts index 877f56152..6ede557f6 100644 --- a/apps/server/sources/app/api/socket/sessionUpdateHandler.ts +++ b/apps/server/sources/app/api/socket/sessionUpdateHandler.ts @@ -9,6 +9,7 @@ import { Socket } from "socket.io"; import { createSessionMessage, updateSessionAgentState, updateSessionMetadata } from "@/app/session/sessionWriteService"; import { recordSessionAlive } from "@/app/presence/presenceRecorder"; import { materializeNextPendingMessage } from "@/app/session/pending/pendingMessageService"; +import { normalizeIncomingSessionMessageContent } from "@/app/session/messageContent/normalizeIncomingSessionMessageContent"; export function sessionUpdateHandler(userId: string, socket: Socket, connection: ClientConnection) { socket.on('update-metadata', async (data: any, callback: (response: any) => void) => { @@ -182,25 +183,33 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: try { websocketEventsCounter.inc({ event_type: 'message' }); const sid = typeof data?.sid === 'string' ? data.sid : null; - const message = typeof data?.message === 'string' ? data.message : null; + const content = normalizeIncomingSessionMessageContent(data?.message); const localId = typeof data?.localId === 'string' ? data.localId : null; const echoToSender = data?.echoToSender === true; - if (!sid || !message) { + if (!sid || !content) { socketMessageAckCounter.inc({ result: 'error', error: 'invalid-params' }); respond({ ok: false, error: 'invalid-params' }); return; } + const loggedLength = (() => { + if (content.t === "encrypted") return content.c.length; + try { + return JSON.stringify(content.v ?? null).length; + } catch { + return 0; + } + })(); log( { module: 'websocket' }, - `Received message from socket ${socket.id}: sessionId=${sid}, messageLength=${message.length} bytes, connectionType=${connection.connectionType}, connectionSessionId=${connection.connectionType === 'session-scoped' ? connection.sessionId : 'N/A'}` + `Received message from socket ${socket.id}: sessionId=${sid}, messageLength=${loggedLength} bytes, connectionType=${connection.connectionType}, connectionSessionId=${connection.connectionType === 'session-scoped' ? connection.sessionId : 'N/A'}` ); const result = await createSessionMessage({ actorUserId: userId, sessionId: sid, - ciphertext: message, + content, localId, }); diff --git a/apps/server/sources/app/api/utils/apiRateLimitPolicy.spec.ts b/apps/server/sources/app/api/utils/apiRateLimitPolicy.spec.ts new file mode 100644 index 000000000..d67168c23 --- /dev/null +++ b/apps/server/sources/app/api/utils/apiRateLimitPolicy.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { + createApiRateLimitKeyGenerator, + gateRateLimitConfig, + resolveApiRateLimitPluginOptions, + resolveApiTrustProxy, + resolveRouteRateLimit, +} from "./apiRateLimitPolicy"; + +describe("apiRateLimitPolicy", () => { + it("disables all rate limiting when HAPPIER_API_RATE_LIMITS_ENABLED=0", () => { + const env = { + HAPPIER_API_RATE_LIMITS_ENABLED: "0", + HAPPIER_API_RATE_LIMITS_GLOBAL_MAX: "100", + HAPPIER_API_RATE_LIMITS_GLOBAL_WINDOW: "1 minute", + } as const; + + expect(resolveApiRateLimitPluginOptions(env)).toEqual({ global: false }); + expect( + resolveRouteRateLimit(env, { + maxEnvKey: "HAPPIER_SESSION_MESSAGES_RATE_LIMIT_MAX", + windowEnvKey: "HAPPIER_SESSION_MESSAGES_RATE_LIMIT_WINDOW", + defaultMax: 600, + defaultWindow: "1 minute", + }), + ).toBe(false); + }); + + it("enables global rate limiting when global max is set", () => { + const env = { + HAPPIER_API_RATE_LIMITS_ENABLED: "1", + HAPPIER_API_RATE_LIMITS_GLOBAL_MAX: "123", + HAPPIER_API_RATE_LIMITS_GLOBAL_WINDOW: "30 seconds", + } as const; + + expect(resolveApiRateLimitPluginOptions(env)).toEqual( + expect.objectContaining({ + global: true, + max: 123, + timeWindow: "30 seconds", + keyGenerator: expect.any(Function), + }), + ); + }); + + it("parses HAPPIER_SERVER_TRUST_PROXY as a boolean or hop count", () => { + expect(resolveApiTrustProxy({})).toBeUndefined(); + expect(resolveApiTrustProxy({ HAPPIER_SERVER_TRUST_PROXY: "true" })).toBe(true); + expect(resolveApiTrustProxy({ HAPPIER_SERVER_TRUST_PROXY: "1" })).toBe(true); + expect(resolveApiTrustProxy({ HAPPIER_SERVER_TRUST_PROXY: "false" })).toBe(false); + expect(resolveApiTrustProxy({ HAPPIER_SERVER_TRUST_PROXY: "0" })).toBe(false); + expect(resolveApiTrustProxy({ HAPPIER_SERVER_TRUST_PROXY: "2" })).toBe(2); + }); + + it("builds a stable rate limit key from Authorization when present", () => { + const keyGen = createApiRateLimitKeyGenerator(); + const token = "Bearer secret-token-value"; + const key = keyGen({ headers: { authorization: token }, ip: "203.0.113.9" }); + expect(key).toMatch(/^auth:/); + expect(key).not.toContain("secret-token-value"); + + const fallback = keyGen({ headers: {}, ip: "203.0.113.9" }); + expect(fallback).toBe("ip:203.0.113.9"); + }); + + it("can derive the IP rate limit key from X-Forwarded-For when configured", () => { + const keyGen = createApiRateLimitKeyGenerator({ + HAPPIER_API_RATE_LIMIT_CLIENT_IP_SOURCE: "x-forwarded-for", + }); + const key = keyGen({ + headers: { "x-forwarded-for": "203.0.113.10, 10.0.0.2" }, + ip: "10.0.0.2", + }); + expect(key).toBe("ip:203.0.113.10"); + }); + + it("falls back to request.ip when configured forwarded IP header is missing", () => { + const keyGen = createApiRateLimitKeyGenerator({ + HAPPIER_API_RATE_LIMIT_CLIENT_IP_SOURCE: "x-forwarded-for", + }); + const key = keyGen({ headers: {}, ip: "203.0.113.9" }); + expect(key).toBe("ip:203.0.113.9"); + }); + + it("supports auth-only keying mode (unauth requests share a bucket)", () => { + const keyGen = createApiRateLimitKeyGenerator({ HAPPIER_API_RATE_LIMIT_KEY_MODE: "auth-only" }); + expect(keyGen({ headers: {}, ip: "203.0.113.9" })).toBe("auth:missing"); + expect(keyGen({ headers: { authorization: "Bearer a" }, ip: "203.0.113.9" })).toMatch(/^auth:/); + }); + + it("gates fixed route rate limits behind HAPPIER_API_RATE_LIMITS_ENABLED", () => { + const enabledEnv = { HAPPIER_API_RATE_LIMITS_ENABLED: "1" } as const; + const disabledEnv = { HAPPIER_API_RATE_LIMITS_ENABLED: "0" } as const; + const config = { max: 10, timeWindow: "1 minute" } as const; + + expect(gateRateLimitConfig(enabledEnv, config)).toEqual(config); + expect(gateRateLimitConfig(disabledEnv, config)).toBe(false); + }); +}); diff --git a/apps/server/sources/app/api/utils/apiRateLimitPolicy.ts b/apps/server/sources/app/api/utils/apiRateLimitPolicy.ts new file mode 100644 index 000000000..fb8a312a6 --- /dev/null +++ b/apps/server/sources/app/api/utils/apiRateLimitPolicy.ts @@ -0,0 +1,184 @@ +import { parseBooleanEnv, parseIntEnv } from "@/config/env"; +import { createHash } from "node:crypto"; +import { isIP } from "node:net"; + +export type ApiRouteRateLimitConfig = + | false + | Readonly<{ + max: number; + timeWindow: string; + keyGenerator?: (request: any) => string; + }>; + +type ApiRateLimitKeyMode = "auth-or-ip" | "auth-only" | "ip-only"; +type ApiRateLimitClientIpSource = "fastify" | "x-forwarded-for" | "x-real-ip"; + +function resolveApiRateLimitKeyMode(env: Record<string, string | undefined>): ApiRateLimitKeyMode { + const raw = (env.HAPPIER_API_RATE_LIMIT_KEY_MODE ?? "").trim().toLowerCase(); + if (!raw || raw === "default") return "auth-or-ip"; + if (["auth-or-ip", "auth_or_ip", "authorip"].includes(raw)) return "auth-or-ip"; + if (["auth-only", "auth_only", "auth"].includes(raw)) return "auth-only"; + if (["ip-only", "ip_only", "ip"].includes(raw)) return "ip-only"; + return "auth-or-ip"; +} + +function resolveApiRateLimitClientIpSource(env: Record<string, string | undefined>): ApiRateLimitClientIpSource { + const raw = (env.HAPPIER_API_RATE_LIMIT_CLIENT_IP_SOURCE ?? "").trim().toLowerCase(); + if (!raw || raw === "fastify" || raw === "request-ip" || raw === "ip") return "fastify"; + if (["x-forwarded-for", "x_forwarded_for", "forwarded", "xff"].includes(raw)) return "x-forwarded-for"; + if (["x-real-ip", "x_real_ip", "xrealip"].includes(raw)) return "x-real-ip"; + return "fastify"; +} + +function normalizeProxyIpCandidate(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + // Common patterns: + // - "203.0.113.10" + // - "203.0.113.10:1234" + // - "[2001:db8::1]:1234" + // - "2001:db8::1" + if (trimmed.startsWith("[")) { + const end = trimmed.indexOf("]"); + if (end > 1) { + const inside = trimmed.slice(1, end); + return isIP(inside) ? inside : null; + } + } + + if (isIP(trimmed)) return trimmed; + + // IPv4 with port (avoid corrupting IPv6 by only attempting when there is a single ':'). + const parts = trimmed.split(":"); + if (parts.length === 2 && isIP(parts[0] ?? "")) { + return parts[0]!; + } + + return null; +} + +function resolveRateLimitClientIpFromRequest( + request: any, + source: ApiRateLimitClientIpSource, +): string | null { + const headers: Record<string, unknown> | null = + request?.headers && typeof request.headers === "object" ? (request.headers as any) : null; + + const fastifyIp = typeof request?.ip === "string" && request.ip.trim().length > 0 ? request.ip.trim() : null; + + if (source === "x-forwarded-for") { + const raw = headers?.["x-forwarded-for"]; + if (typeof raw === "string" && raw.trim().length > 0) { + const first = raw.split(",")[0] ?? ""; + const normalized = normalizeProxyIpCandidate(first); + if (normalized) return normalized; + } + return fastifyIp; + } + + if (source === "x-real-ip") { + const raw = headers?.["x-real-ip"]; + if (typeof raw === "string" && raw.trim().length > 0) { + const normalized = normalizeProxyIpCandidate(raw); + if (normalized) return normalized; + } + return fastifyIp; + } + + return fastifyIp; +} + +function buildAuthKey(authHeader: string): string { + const hash = createHash("sha256").update(authHeader, "utf8").digest("hex").slice(0, 32); + return `auth:${hash}`; +} + +export function createApiRateLimitKeyGenerator( + env: Record<string, string | undefined> = {}, +): (request: any) => string { + const mode = resolveApiRateLimitKeyMode(env); + const ipSource = resolveApiRateLimitClientIpSource(env); + + return (request: any) => { + const authHeader = request?.headers?.authorization; + const hasAuth = typeof authHeader === "string" && authHeader.trim().length > 0; + + if (mode !== "ip-only" && hasAuth) { + return buildAuthKey(authHeader); + } + + if (mode === "auth-only") { + return "auth:missing"; + } + + const ip = resolveRateLimitClientIpFromRequest(request, ipSource); + return ip ? `ip:${ip}` : "ip:unknown"; + }; +} + +export function gateRateLimitConfig( + env: Record<string, string | undefined>, + rateLimit: ApiRouteRateLimitConfig, +): ApiRouteRateLimitConfig { + const enabled = parseBooleanEnv(env.HAPPIER_API_RATE_LIMITS_ENABLED, true); + if (!enabled) return false; + return rateLimit; +} + +export function resolveApiRateLimitPluginOptions( + env: Record<string, string | undefined>, +): Readonly<{ global: boolean; max?: number; timeWindow?: string; keyGenerator?: (request: any) => string }> { + const enabled = parseBooleanEnv(env.HAPPIER_API_RATE_LIMITS_ENABLED, true); + if (!enabled) { + return { global: false }; + } + + const globalMax = parseIntEnv(env.HAPPIER_API_RATE_LIMITS_GLOBAL_MAX, 0, { min: 0 }); + const windowRaw = (env.HAPPIER_API_RATE_LIMITS_GLOBAL_WINDOW ?? "").trim(); + const timeWindow = windowRaw.length > 0 ? windowRaw : "1 minute"; + + const keyGenerator = createApiRateLimitKeyGenerator(env); + if (globalMax <= 0) { + return { global: false, keyGenerator }; + } + + return { global: true, max: globalMax, timeWindow, keyGenerator }; +} + +export function resolveRouteRateLimit( + env: Record<string, string | undefined>, + params: Readonly<{ + maxEnvKey: string; + windowEnvKey: string; + defaultMax: number; + defaultWindow: string; + keyGenerator?: (request: any) => string; + }>, +): ApiRouteRateLimitConfig { + const enabled = parseBooleanEnv(env.HAPPIER_API_RATE_LIMITS_ENABLED, true); + if (!enabled) return false; + + const maxRaw = env[params.maxEnvKey]; + const max = parseIntEnv(maxRaw, params.defaultMax, { min: 0 }); + if (max <= 0) return false; + + const windowRaw = (env[params.windowEnvKey] ?? "").trim(); + const timeWindow = windowRaw.length > 0 ? windowRaw : params.defaultWindow; + + return { + max, + timeWindow, + ...(params.keyGenerator ? { keyGenerator: params.keyGenerator } : null), + }; +} + +export function resolveApiTrustProxy(env: Record<string, string | undefined>): boolean | number | undefined { + const raw = (env.HAPPIER_SERVER_TRUST_PROXY ?? "").trim().toLowerCase(); + if (!raw) return undefined; + if (["1", "true", "yes", "on"].includes(raw)) return true; + if (["0", "false", "no", "off"].includes(raw)) return false; + const hops = parseInt(raw, 10); + if (Number.isFinite(hops) && hops >= 0) return hops; + return undefined; +} diff --git a/apps/server/sources/app/auth/auth.oauthState.spec.ts b/apps/server/sources/app/auth/auth.oauthState.spec.ts index 350f4ad59..5b80848ed 100644 --- a/apps/server/sources/app/auth/auth.oauthState.spec.ts +++ b/apps/server/sources/app/auth/auth.oauthState.spec.ts @@ -37,6 +37,7 @@ describe("auth (oauth state tokens)", () => { sid: "sid_1", userId: "u1", publicKey: null, + proofHash: null, }); const authToken = await (auth as any).createOauthStateToken({ @@ -52,6 +53,27 @@ describe("auth (oauth state tokens)", () => { sid: "sid_2", userId: null, publicKey: "pk_hex_1", + proofHash: null, + }); + }); + + it("round-trips keyless oauth state tokens with a proof hash", async () => { + const token = await (auth as any).createOauthStateToken({ + flow: "auth", + provider: "github", + sid: "sid_keyless_1", + publicKey: null, + proofHash: "sha256hex_1", + }); + + const verified = await (auth as any).verifyOauthStateToken(token); + expect(verified).toEqual({ + flow: "auth", + provider: "github", + sid: "sid_keyless_1", + userId: null, + publicKey: null, + proofHash: "sha256hex_1", }); }); diff --git a/apps/server/sources/app/auth/auth.ts b/apps/server/sources/app/auth/auth.ts index 3f6c334d7..57520a01e 100644 --- a/apps/server/sources/app/auth/auth.ts +++ b/apps/server/sources/app/auth/auth.ts @@ -32,6 +32,7 @@ type OAuthStatePayload = Readonly<{ sid?: string | null; userId?: string | null; publicKey?: string | null; + proofHash?: string | null; }>; class AuthModule { @@ -224,7 +225,7 @@ class AuthModule { return token; } - + async verifyToken(token: string): Promise<{ userId: string; extras?: any } | null> { // Check cache first const cached = this.tokenCache?.get(token); @@ -311,6 +312,7 @@ class AuthModule { const sid = payload.sid?.toString().trim() || null; const userId = payload.userId?.toString().trim() || null; const publicKey = payload.publicKey?.toString().trim() || null; + const proofHash = payload.proofHash?.toString().trim() || null; return await oauthStateTokens.oauthStateGenerator.new({ user: "oauth-state", @@ -320,6 +322,7 @@ class AuthModule { sid, userId, publicKey, + proofHash, }, }); } @@ -330,6 +333,7 @@ class AuthModule { sid: string | null; userId: string | null; publicKey: string | null; + proofHash: string | null; } | null> { if (!this.tokens) { throw new Error("Auth module not initialized"); @@ -357,6 +361,10 @@ class AuthModule { typeof extras.publicKey === "string" && extras.publicKey.trim() ? extras.publicKey.trim() : null, + proofHash: + typeof extras.proofHash === "string" && extras.proofHash.trim() + ? extras.proofHash.trim() + : null, }; } catch (error) { if (isOAuthStateUnavailableError(error)) { diff --git a/apps/server/sources/app/auth/keyless/resolveKeylessAutoProvisionEligibility.ts b/apps/server/sources/app/auth/keyless/resolveKeylessAutoProvisionEligibility.ts new file mode 100644 index 000000000..e866bec93 --- /dev/null +++ b/apps/server/sources/app/auth/keyless/resolveKeylessAutoProvisionEligibility.ts @@ -0,0 +1,20 @@ +import { resolveEffectiveDefaultAccountEncryptionMode } from "@happier-dev/protocol"; + +import { readEncryptionFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { resolveKeylessAccountsEnabled } from "@/app/features/e2ee/resolveKeylessAccountsEnabled"; + +export type KeylessAutoProvisionEligibility = + | Readonly<{ ok: true; encryptionMode: "plain" }> + | Readonly<{ ok: false; error: "e2ee-required" }>; + +export function resolveKeylessAutoProvisionEligibility(env: NodeJS.ProcessEnv): KeylessAutoProvisionEligibility { + const encryptionEnv = readEncryptionFeatureEnv(env); + const effectiveDefaultEncryptionMode = resolveEffectiveDefaultAccountEncryptionMode( + encryptionEnv.storagePolicy, + encryptionEnv.defaultAccountMode, + ); + const canProvisionKeyless = resolveKeylessAccountsEnabled(env) && effectiveDefaultEncryptionMode === "plain"; + if (!canProvisionKeyless) return { ok: false, error: "e2ee-required" }; + return { ok: true, encryptionMode: "plain" }; +} + diff --git a/apps/server/sources/app/auth/methods/modules/keyChallengeAuthMethodModule.ts b/apps/server/sources/app/auth/methods/modules/keyChallengeAuthMethodModule.ts new file mode 100644 index 000000000..4c1b0dd46 --- /dev/null +++ b/apps/server/sources/app/auth/methods/modules/keyChallengeAuthMethodModule.ts @@ -0,0 +1,31 @@ +import type { AuthMethodModule } from "@/app/auth/methods/types"; + +import { registerKeyChallengeAuthRoute } from "@/app/api/routes/auth/registerKeyChallengeAuthRoute"; +import { readAuthFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; + +export const keyChallengeAuthMethodModule: AuthMethodModule = Object.freeze({ + id: "key_challenge", + resolveAuthMethod: ({ env, policy }) => { + const featureEnv = readAuthFeatureEnv(env); + const loginEnabled = featureEnv.loginKeyChallengeEnabled; + const provisionEnabled = loginEnabled && policy.anonymousSignupEnabled; + return { + id: "key_challenge", + actions: [ + { id: "login", enabled: loginEnabled, mode: "keyed" }, + { id: "provision", enabled: provisionEnabled, mode: "keyed" }, + ], + ui: { displayName: "Device key", iconHint: null }, + }; + }, + isViable: (env) => { + const featureEnv = readAuthFeatureEnv(env); + return featureEnv.loginKeyChallengeEnabled; + }, + registerRoutes: (app) => { + const featureEnv = readAuthFeatureEnv(process.env); + if (!featureEnv.loginKeyChallengeEnabled) return; + registerKeyChallengeAuthRoute(app); + }, +}); + diff --git a/apps/server/sources/app/auth/methods/modules/mtlsAuthMethodModule.ts b/apps/server/sources/app/auth/methods/modules/mtlsAuthMethodModule.ts new file mode 100644 index 000000000..df5b7d79a --- /dev/null +++ b/apps/server/sources/app/auth/methods/modules/mtlsAuthMethodModule.ts @@ -0,0 +1,34 @@ +import type { AuthMethodModule } from "@/app/auth/methods/types"; + +import { registerMtlsAuthRoutes } from "@/app/auth/providers/mtls/registerMtlsAuthRoutes"; +import { readAuthMtlsFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { resolveKeylessAutoProvisionEligibility } from "@/app/auth/keyless/resolveKeylessAutoProvisionEligibility"; +import { resolveKeylessAccountsEnabled } from "@/app/features/e2ee/resolveKeylessAccountsEnabled"; + +function isMtlsGateEnabled(env: NodeJS.ProcessEnv): boolean { + const mtlsEnv = readAuthMtlsFeatureEnv(env); + if (!mtlsEnv.enabled) return false; + if (mtlsEnv.mode !== "forwarded") return false; + if (!mtlsEnv.trustForwardedHeaders) return false; + return true; +} + +export const mtlsAuthMethodModule: AuthMethodModule = Object.freeze({ + id: "mtls", + resolveAuthMethod: ({ env }) => { + const mtlsEnv = readAuthMtlsFeatureEnv(env); + const gateEnabled = isMtlsGateEnabled(env); + const keylessEnabled = resolveKeylessAccountsEnabled(env); + const provisionEligible = resolveKeylessAutoProvisionEligibility(env).ok; + return { + id: "mtls", + actions: [ + { id: "login", enabled: gateEnabled && keylessEnabled, mode: "keyless" }, + { id: "provision", enabled: gateEnabled && mtlsEnv.autoProvision && provisionEligible, mode: "keyless" }, + ], + ui: { displayName: "Certificate", iconHint: null }, + }; + }, + isViable: (env) => isMtlsGateEnabled(env), + registerRoutes: (app) => registerMtlsAuthRoutes(app), +}); diff --git a/apps/server/sources/app/auth/methods/registry.ts b/apps/server/sources/app/auth/methods/registry.ts new file mode 100644 index 000000000..7571df458 --- /dev/null +++ b/apps/server/sources/app/auth/methods/registry.ts @@ -0,0 +1,15 @@ +import type { AuthMethodModule } from "@/app/auth/methods/types"; + +import { keyChallengeAuthMethodModule } from "@/app/auth/methods/modules/keyChallengeAuthMethodModule"; +import { mtlsAuthMethodModule } from "@/app/auth/methods/modules/mtlsAuthMethodModule"; + +const staticAuthMethodModules: readonly AuthMethodModule[] = Object.freeze([ + keyChallengeAuthMethodModule, + mtlsAuthMethodModule, +]); + +export function resolveAuthMethodRegistry(_env: NodeJS.ProcessEnv): readonly AuthMethodModule[] { + // For now, methods are a static registry. Enterprise forks can extend this list safely. + return staticAuthMethodModules; +} + diff --git a/apps/server/sources/app/auth/methods/types.ts b/apps/server/sources/app/auth/methods/types.ts new file mode 100644 index 000000000..4dc658f79 --- /dev/null +++ b/apps/server/sources/app/auth/methods/types.ts @@ -0,0 +1,20 @@ +import type { Fastify } from "@/app/api/types"; +import type { AuthPolicy } from "@/app/auth/authPolicy"; +import type { FeaturesResponse } from "@/app/features/types"; + +export type AuthMethod = NonNullable<FeaturesResponse["capabilities"]["auth"]["methods"]>[number]; + +export type AuthMethodModule = Readonly<{ + id: string; + resolveAuthMethod: (params: { env: NodeJS.ProcessEnv; policy: AuthPolicy }) => AuthMethod; + /** + * Whether this method counts as a viable non-key-challenge auth entrypoint for lockout prevention. + * This should be `true` only when the method is enabled and correctly configured. + */ + isViable: (env: NodeJS.ProcessEnv) => boolean; + /** + * Register any routes required by this auth method. Modules must enforce their own gating/config. + */ + registerRoutes: (app: Fastify) => void; +}>; + diff --git a/apps/server/sources/app/auth/providers/mtls/mtlsIdentity.spec.ts b/apps/server/sources/app/auth/providers/mtls/mtlsIdentity.spec.ts new file mode 100644 index 000000000..ecba4c037 --- /dev/null +++ b/apps/server/sources/app/auth/providers/mtls/mtlsIdentity.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMtlsIdentityFromForwardedHeaders } from "@/app/auth/providers/mtls/mtlsIdentity"; + +describe("resolveMtlsIdentityFromForwardedHeaders", () => { + it("returns null when forwarded headers are not trusted", () => { + const identity = resolveMtlsIdentityFromForwardedHeaders({ + env: { + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "0", + } as any, + headers: { + "x-happier-client-cert-email": "alice@example.com", + }, + }); + + expect(identity).toBeNull(); + }); + + it("normalizes SAN email/UPN to lowercase and includes issuer in the profile", () => { + const identity = resolveMtlsIdentityFromForwardedHeaders({ + env: { + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "san_email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: "x-happier-client-cert-email", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_UPN_HEADER: "x-happier-client-cert-upn", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER: "x-happier-client-cert-issuer", + } as any, + headers: { + "x-happier-client-cert-email": " Alice@Example.com ", + "x-happier-client-cert-upn": " ALICE@EXAMPLE.COM ", + "x-happier-client-cert-issuer": " CN=Example Root CA ", + }, + }); + + expect(identity).toEqual({ + providerUserId: "alice@example.com", + providerLogin: "alice@example.com", + profile: { + email: "alice@example.com", + upn: "alice@example.com", + subject: null, + fingerprint: null, + issuer: "CN=Example Root CA", + }, + }); + }); + + it("extracts CN from the subject when identitySource=subject_cn", () => { + const identity = resolveMtlsIdentityFromForwardedHeaders({ + env: { + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: "subject_cn", + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_SUBJECT_HEADER: "x-happier-client-cert-subject", + } as any, + headers: { + "x-happier-client-cert-subject": "CN=Alice Example,OU=Users,O=Example Corp", + }, + }); + + expect(identity?.providerUserId).toBe("Alice Example"); + expect(identity?.profile.subject).toBe("CN=Alice Example,OU=Users,O=Example Corp"); + }); +}); + diff --git a/apps/server/sources/app/auth/providers/mtls/mtlsIdentity.ts b/apps/server/sources/app/auth/providers/mtls/mtlsIdentity.ts new file mode 100644 index 000000000..978381a0e --- /dev/null +++ b/apps/server/sources/app/auth/providers/mtls/mtlsIdentity.ts @@ -0,0 +1,101 @@ +import { readAuthMtlsFeatureEnv, type AuthMtlsIdentitySource } from "@/app/features/catalog/readFeatureEnv"; + +function readSingleHeader(headers: Record<string, unknown>, headerNameLower: string): string | null { + const raw = headers[headerNameLower]; + if (typeof raw === "string") return raw.trim() || null; + if (Array.isArray(raw)) { + const first = raw.find((v) => typeof v === "string" && v.trim()); + return typeof first === "string" ? first.trim() : null; + } + return null; +} + +function normalizeEmailLikeIdentity(raw: string | null): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + return trimmed ? trimmed.toLowerCase() : null; +} + +function normalizeIssuer(raw: string | null): string | null { + if (!raw) return null; + const trimmed = raw.trim().replace(/\s+/g, " "); + return trimmed ? trimmed : null; +} + +function extractSubjectCommonName(subject: string | null): string | null { + if (!subject) return null; + // Best-effort extraction for forwarded X.509 subject strings like: + // "CN=Alice Example,OU=Users,O=Example Corp" + const match = subject.match(/(?:^|,)\s*CN\s*=\s*([^,]+)\s*(?:,|$)/i); + const cn = match?.[1]?.trim() ?? ""; + return cn || null; +} + +function pickIdentityValue(params: { + identitySource: AuthMtlsIdentitySource; + email: string | null; + upn: string | null; + subject: string | null; + fingerprint: string | null; +}): string | null { + switch (params.identitySource) { + case "san_email": + return params.email; + case "san_upn": + return params.upn; + case "subject_cn": + return extractSubjectCommonName(params.subject); + case "fingerprint": + return params.fingerprint; + } +} + +export type MtlsIdentity = Readonly<{ + providerUserId: string; + providerLogin: string | null; + profile: Readonly<{ + email: string | null; + upn: string | null; + subject: string | null; + fingerprint: string | null; + issuer: string | null; + }>; +}>; + +export function resolveMtlsIdentityFromForwardedHeaders(params: { + env: NodeJS.ProcessEnv; + headers: Record<string, unknown>; +}): MtlsIdentity | null { + const mtlsEnv = readAuthMtlsFeatureEnv(params.env); + if (!mtlsEnv.trustForwardedHeaders) { + return null; + } + + const email = normalizeEmailLikeIdentity(readSingleHeader(params.headers, mtlsEnv.forwardedEmailHeader)); + const upn = normalizeEmailLikeIdentity(readSingleHeader(params.headers, mtlsEnv.forwardedUpnHeader)); + const subject = readSingleHeader(params.headers, mtlsEnv.forwardedSubjectHeader); + const fingerprintRaw = readSingleHeader(params.headers, mtlsEnv.forwardedFingerprintHeader); + const fingerprint = fingerprintRaw ? fingerprintRaw.replace(/^sha256:/i, "").trim() : null; + const issuer = normalizeIssuer(readSingleHeader(params.headers, mtlsEnv.forwardedIssuerHeader)); + + const providerUserId = pickIdentityValue({ + identitySource: mtlsEnv.identitySource, + email, + upn, + subject, + fingerprint, + }); + if (!providerUserId) return null; + + return { + providerUserId, + providerLogin: mtlsEnv.identitySource === "san_email" ? providerUserId : null, + profile: { + email, + upn, + subject, + fingerprint, + issuer, + }, + }; +} diff --git a/apps/server/sources/app/auth/providers/mtls/registerMtlsAuthRoutes.ts b/apps/server/sources/app/auth/providers/mtls/registerMtlsAuthRoutes.ts new file mode 100644 index 000000000..174f5d0f7 --- /dev/null +++ b/apps/server/sources/app/auth/providers/mtls/registerMtlsAuthRoutes.ts @@ -0,0 +1,381 @@ +import { z } from "zod"; + +import type { Fastify } from "@/app/api/types"; +import { db } from "@/storage/db"; +import { auth } from "@/app/auth/auth"; +import { readAuthMtlsFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { resolveMtlsIdentityFromForwardedHeaders } from "@/app/auth/providers/mtls/mtlsIdentity"; +import { resolveKeylessAutoProvisionEligibility } from "@/app/auth/keyless/resolveKeylessAutoProvisionEligibility"; +import { resolveKeylessAccountsEnabled } from "@/app/features/e2ee/resolveKeylessAccountsEnabled"; +import { randomKeyNaked } from "@/utils/keys/randomKeyNaked"; + +type ForwardedMtlsIdentity = NonNullable<ReturnType<typeof resolveMtlsIdentityFromForwardedHeaders>>; + +function isMtlsLoginEnabled(env: NodeJS.ProcessEnv): boolean { + const mtlsEnv = readAuthMtlsFeatureEnv(env); + if (!mtlsEnv.enabled) return false; + if (mtlsEnv.mode !== "forwarded") return false; + if (!mtlsEnv.trustForwardedHeaders) return false; + if (!resolveKeylessAccountsEnabled(env)) return false; + return true; +} + +async function resolveOrProvisionMtlsAccount(params: { + identity: ForwardedMtlsIdentity; +}): Promise<{ accountId: string } | { error: "not-eligible" | "e2ee-required" }> { + const mtlsEnv = readAuthMtlsFeatureEnv(process.env); + + const existing = await db.accountIdentity.findFirst({ + where: { provider: "mtls", providerUserId: params.identity.providerUserId }, + select: { accountId: true }, + }); + if (existing) { + return { accountId: existing.accountId }; + } + + if (!mtlsEnv.autoProvision) { + return { error: "not-eligible" }; + } + + const eligibility = resolveKeylessAutoProvisionEligibility(process.env); + if (!eligibility.ok) { + return { error: eligibility.error }; + } + + const created = await db.account.create({ + data: { + publicKey: null, + encryptionMode: eligibility.encryptionMode, + }, + select: { id: true }, + }); + const accountId = created.id; + + await db.accountIdentity.create({ + data: { + accountId, + provider: "mtls", + providerUserId: params.identity.providerUserId, + providerLogin: params.identity.providerLogin, + profile: params.identity.profile as any, + showOnProfile: false, + }, + }); + + return { accountId }; +} + +function effectivePort(url: URL): string { + if (url.port) return url.port; + const protocol = url.protocol.toLowerCase(); + if (protocol === "https:") return "443"; + if (protocol === "http:") return "80"; + return ""; +} + +function normalizePathPrefix(pathname: string): string { + const raw = pathname || "/"; + const stripped = raw.replace(/\/+$/, ""); + return stripped && stripped !== "/" ? stripped : ""; +} + +function isPathPrefixMatch(params: { allowedPrefix: string; pathname: string }): boolean { + const allowed = normalizePathPrefix(params.allowedPrefix); + if (!allowed) return true; + const path = params.pathname || "/"; + if (path === allowed) return true; + if (path.startsWith(`${allowed}/`)) return true; + return false; +} + +function isAllowedReturnTo(params: { returnTo: string; allowPrefixes: readonly string[] }): boolean { + const raw = params.returnTo.toString().trim(); + if (!raw) return false; + + let returnUrl: URL; + try { + returnUrl = new URL(raw); + } catch { + return false; + } + + for (const allow of params.allowPrefixes) { + const entry = allow.toString().trim(); + if (!entry) continue; + + // Allow custom-scheme prefixes like "happier://". + const schemeOnlyMatch = entry.match(/^([a-z][a-z0-9+.-]*)\:\/\/$/i); + if (schemeOnlyMatch) { + const scheme = schemeOnlyMatch[1]!.toLowerCase(); + const actual = returnUrl.protocol.replace(/:$/, "").toLowerCase(); + if (actual === scheme) return true; + continue; + } + + // Allow prefix matching for non-http(s) deep link URLs (e.g. "happier:///mtls"). + // For http(s), prefix matching is unsafe (origin confusion), so those entries are parsed below. + const looksLikeUrlPrefix = /^[a-z][a-z0-9+.-]*:\/\//i.test(entry); + const isHttpPrefix = /^https?:\/\//i.test(entry); + if (looksLikeUrlPrefix && !isHttpPrefix) { + if (raw.toLowerCase().startsWith(entry.toLowerCase())) return true; + continue; + } + + let allowedUrl: URL; + try { + allowedUrl = new URL(entry); + } catch { + // Fail closed on invalid allowlist entries. + continue; + } + + const allowedProtocol = allowedUrl.protocol.toLowerCase(); + if (allowedProtocol !== "https:" && allowedProtocol !== "http:") { + // Only http/https allowlist entries are supported here; other schemes should use the scheme-only form above. + continue; + } + + if (returnUrl.protocol.toLowerCase() !== allowedProtocol) continue; + if (returnUrl.hostname.toLowerCase() !== allowedUrl.hostname.toLowerCase()) continue; + if (effectivePort(returnUrl) !== effectivePort(allowedUrl)) continue; + + if (!isPathPrefixMatch({ allowedPrefix: allowedUrl.pathname, pathname: returnUrl.pathname })) continue; + return true; + } + + return false; +} + +const MTLS_CLAIM_CODE_PREFIX = "mtls_claim_"; + +async function createMtlsClaimCode(params: { userId: string; ttlMs: number }): Promise<string> { + const ttlMs = Number.isFinite(params.ttlMs) && params.ttlMs > 0 ? params.ttlMs : 60_000; + for (let i = 0; i < 3; i++) { + const code = randomKeyNaked(32); + const key = `${MTLS_CLAIM_CODE_PREFIX}${code}`; + try { + await db.repeatKey.create({ + data: { + key, + value: JSON.stringify({ userId: params.userId }), + expiresAt: new Date(Date.now() + ttlMs), + }, + }); + return code; + } catch { + // retry on rare collisions + } + } + // Extremely unlikely; treat as hard failure. + throw new Error("mtls-claim-code-unavailable"); +} + +async function consumeMtlsClaimCode(code: string): Promise<{ userId: string } | null> { + const raw = code.toString().trim(); + if (!raw) return null; + const key = `${MTLS_CLAIM_CODE_PREFIX}${raw}`; + + return await db.$transaction(async (tx) => { + const row = await tx.repeatKey.findUnique({ + where: { key }, + select: { value: true, expiresAt: true }, + }); + if (!row) return null; + const now = new Date(); + + // Consume via a conditional delete to ensure single-use semantics under concurrency. + const deleted = await tx.repeatKey.deleteMany({ + where: { + key, + expiresAt: { gt: now }, + }, + }); + if (deleted.count !== 1) { + // Best-effort cleanup of expired/invalid rows. + await tx.repeatKey.deleteMany({ where: { key } }).catch(() => undefined); + return null; + } + try { + const parsed = JSON.parse(row.value) as any; + const userId = typeof parsed?.userId === "string" ? parsed.userId.trim() : ""; + if (!userId) return null; + return { userId }; + } catch { + return null; + } + }); +} + +function isAllowedEmailIdentity(params: { providerUserId: string; allowedDomains: readonly string[] }): boolean { + if (params.allowedDomains.length === 0) return true; + const atIndex = params.providerUserId.lastIndexOf("@"); + const domain = atIndex >= 0 ? params.providerUserId.slice(atIndex + 1).trim().toLowerCase() : ""; + return Boolean(domain) && params.allowedDomains.includes(domain); +} + +function isAllowedIssuer(params: { issuer: string | null; allowedIssuers: readonly string[] }): boolean { + if (params.allowedIssuers.length === 0) return true; + if (!params.issuer) return false; + const normalizedDnLower = params.issuer.trim().replace(/\s+/g, " ").toLowerCase(); + if (!normalizedDnLower) return false; + + const cn = (() => { + if (!normalizedDnLower.includes("=")) return normalizedDnLower; + const match = normalizedDnLower.match(/(?:^|,|\/)\s*cn\s*=\s*([^,\/]+)\s*(?:,|\/|$)/i); + const value = match?.[1]?.trim() ?? ""; + return value || null; + })(); + + const dnEntry = `dn=${normalizedDnLower}`; + const cnEntry = cn ? `cn=${cn}` : null; + + // Allowed list entries are already normalized (via normalizeAuthMtlsIssuerValue at env read time). + if (params.allowedIssuers.includes(dnEntry)) return true; + if (cnEntry && params.allowedIssuers.includes(cnEntry)) return true; + return false; +} + +export function registerMtlsAuthRoutes(app: Fastify): void { + if (!isMtlsLoginEnabled(process.env)) { + return; + } + + app.get( + "/v1/auth/mtls/start", + { + schema: { + querystring: z.object({ + returnTo: z.string(), + }), + response: { + 302: z.any(), + 400: z.object({ error: z.literal("invalid-returnTo") }), + }, + }, + }, + async (request, reply) => { + const mtlsEnv = readAuthMtlsFeatureEnv(process.env); + const returnTo = String((request.query as any)?.returnTo ?? ""); + if (!isAllowedReturnTo({ returnTo, allowPrefixes: mtlsEnv.returnToAllowPrefixes })) { + return reply.code(400).send({ error: "invalid-returnTo" }); + } + + const completeUrl = `/v1/auth/mtls/complete?returnTo=${encodeURIComponent(returnTo)}`; + return reply.redirect(completeUrl); + }, + ); + + app.get( + "/v1/auth/mtls/complete", + { + schema: { + querystring: z.object({ + returnTo: z.string(), + }), + response: { + 302: z.any(), + 400: z.object({ error: z.literal("invalid-returnTo") }), + 401: z.object({ error: z.literal("mtls-required") }), + 403: z.object({ error: z.union([z.literal("e2ee-required"), z.literal("not-eligible")]) }), + }, + }, + }, + async (request, reply) => { + const mtlsEnv = readAuthMtlsFeatureEnv(process.env); + const returnTo = String((request.query as any)?.returnTo ?? ""); + if (!isAllowedReturnTo({ returnTo, allowPrefixes: mtlsEnv.returnToAllowPrefixes })) { + return reply.code(400).send({ error: "invalid-returnTo" }); + } + + const identity = + mtlsEnv.mode === "forwarded" + ? resolveMtlsIdentityFromForwardedHeaders({ + env: process.env, + headers: request.headers as any, + }) + : null; + if (!identity) { + return reply.code(401).send({ error: "mtls-required" }); + } + if (!isAllowedIssuer({ issuer: identity.profile.issuer, allowedIssuers: mtlsEnv.allowedIssuers })) { + return reply.code(403).send({ error: "not-eligible" }); + } + if ((mtlsEnv.identitySource === "san_email" || mtlsEnv.identitySource === "san_upn") && !isAllowedEmailIdentity({ providerUserId: identity.providerUserId, allowedDomains: mtlsEnv.allowedEmailDomains })) { + return reply.code(403).send({ error: "not-eligible" }); + } + + const account = await resolveOrProvisionMtlsAccount({ identity }); + if ("error" in account) { + return reply.code(403).send({ error: account.error }); + } + + const ttlMs = mtlsEnv.claimTtlSeconds * 1000; + const code = await createMtlsClaimCode({ userId: account.accountId, ttlMs }); + const url = new URL(returnTo); + url.searchParams.set("code", code); + return reply.redirect(url.toString()); + }, + ); + + app.post( + "/v1/auth/mtls", + { + schema: { + response: { + 200: z.object({ success: z.literal(true), token: z.string() }), + 401: z.object({ error: z.literal("mtls-required") }), + 403: z.object({ error: z.union([z.literal("e2ee-required"), z.literal("not-eligible")]) }), + }, + }, + }, + async (request, reply) => { + const mtlsEnv = readAuthMtlsFeatureEnv(process.env); + const identity = + mtlsEnv.mode === "forwarded" + ? resolveMtlsIdentityFromForwardedHeaders({ + env: process.env, + headers: request.headers as any, + }) + : null; + if (!identity) { + return reply.code(401).send({ error: "mtls-required" }); + } + if (!isAllowedIssuer({ issuer: identity.profile.issuer, allowedIssuers: mtlsEnv.allowedIssuers })) { + return reply.code(403).send({ error: "not-eligible" }); + } + if ((mtlsEnv.identitySource === "san_email" || mtlsEnv.identitySource === "san_upn") && !isAllowedEmailIdentity({ providerUserId: identity.providerUserId, allowedDomains: mtlsEnv.allowedEmailDomains })) { + return reply.code(403).send({ error: "not-eligible" }); + } + + const account = await resolveOrProvisionMtlsAccount({ identity }); + if ("error" in account) { + return reply.code(403).send({ error: account.error }); + } + + const token = await auth.createToken(account.accountId); + return reply.send({ success: true, token }); + }, + ); + + app.post( + "/v1/auth/mtls/claim", + { + schema: { + body: z.object({ code: z.string() }), + response: { + 200: z.object({ success: z.literal(true), token: z.string() }), + 401: z.object({ error: z.literal("invalid-code") }), + }, + }, + }, + async (request, reply) => { + const code = String((request.body as any)?.code ?? ""); + const verified = await consumeMtlsClaimCode(code); + if (!verified?.userId) { + return reply.code(401).send({ error: "invalid-code" }); + } + const token = await auth.createToken(verified.userId); + return reply.send({ success: true, token }); + }, + ); +} diff --git a/apps/server/sources/app/events/eventPayloadBuilders.ts b/apps/server/sources/app/events/eventPayloadBuilders.ts index fb2091261..2f0f40737 100644 --- a/apps/server/sources/app/events/eventPayloadBuilders.ts +++ b/apps/server/sources/app/events/eventPayloadBuilders.ts @@ -447,7 +447,7 @@ export function buildSessionSharedUpdate(share: { avatar: any | null; }; accessLevel: 'view' | 'edit' | 'admin'; - encryptedDataKey: Uint8Array; + encryptedDataKey: Uint8Array | null; createdAt: Date; }, updateSeq: number, updateId: string): UpdatePayload { return { @@ -461,7 +461,7 @@ export function buildSessionSharedUpdate(share: { shareId: share.id, sharedBy: share.sharedByUser, accessLevel: share.accessLevel, - encryptedDataKey: Buffer.from(share.encryptedDataKey).toString('base64'), + ...(share.encryptedDataKey ? { encryptedDataKey: Buffer.from(share.encryptedDataKey).toString('base64') } : {}), createdAt: share.createdAt.getTime() }, createdAt: Date.now() diff --git a/apps/server/sources/app/events/sharingEvents.spec.ts b/apps/server/sources/app/events/sharingEvents.spec.ts index a2084ccec..9f56a10d3 100644 --- a/apps/server/sources/app/events/sharingEvents.spec.ts +++ b/apps/server/sources/app/events/sharingEvents.spec.ts @@ -35,6 +35,33 @@ describe("sharing event builders", () => { }); }); + it("buildSessionSharedUpdate omits encryptedDataKey when not present", () => { + const share = { + id: "share-1", + sessionId: "session-1", + sharedByUser: { + id: "user-owner", + firstName: "John", + lastName: "Doe", + username: "johndoe", + avatar: null, + }, + accessLevel: "view" as const, + encryptedDataKey: null, + createdAt: new Date("2025-01-09T12:00:00Z"), + }; + + const result = buildSessionSharedUpdate(share, 100, "update-id-1"); + expect(result.body).toMatchObject({ + t: "session-shared", + shareId: "share-1", + sharedBy: share.sharedByUser, + accessLevel: "view", + createdAt: share.createdAt.getTime(), + }); + expect(result.body).not.toHaveProperty("encryptedDataKey"); + }); + it("buildSessionShareUpdatedUpdate maps accessLevel and updatedAt timestamp", () => { const updatedAt = new Date("2025-01-09T13:00:00Z"); const result = buildSessionShareUpdatedUpdate( diff --git a/apps/server/sources/app/features/authFeature.feat.auth.methods.connectAction.spec.ts b/apps/server/sources/app/features/authFeature.feat.auth.methods.connectAction.spec.ts new file mode 100644 index 000000000..79a5d8632 --- /dev/null +++ b/apps/server/sources/app/features/authFeature.feat.auth.methods.connectAction.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAuthFeature } from "./authFeature"; + +function getMethod(feature: any, id: string): any | null { + const methods = feature?.capabilities?.auth?.methods; + if (!Array.isArray(methods)) return null; + const normalized = id.toLowerCase(); + return methods.find((m) => String(m?.id ?? "").toLowerCase() === normalized) ?? null; +} + +describe("resolveAuthFeature (auth.methods connect action)", () => { + it("enables connect when the OAuth provider is configured", () => { + const feature = resolveAuthFeature({ + GITHUB_CLIENT_ID: "id", + GITHUB_CLIENT_SECRET: "secret", + GITHUB_REDIRECT_URL: "https://example.test/v1/oauth/github/callback", + } as NodeJS.ProcessEnv); + + const github = getMethod(feature, "github"); + expect(github?.actions).toEqual( + expect.arrayContaining([{ id: "connect", enabled: true, mode: "either" }]), + ); + }); + + it("disables connect when the OAuth provider is not configured", () => { + const feature = resolveAuthFeature({ + GITHUB_CLIENT_ID: "id", + GITHUB_CLIENT_SECRET: "", + GITHUB_REDIRECT_URL: "", + } as NodeJS.ProcessEnv); + + const github = getMethod(feature, "github"); + expect(github?.actions).toEqual( + expect.arrayContaining([{ id: "connect", enabled: false, mode: "either" }]), + ); + }); +}); + diff --git a/apps/server/sources/app/features/authFeature.feat.auth.methods.spec.ts b/apps/server/sources/app/features/authFeature.feat.auth.methods.spec.ts new file mode 100644 index 000000000..025dd2da5 --- /dev/null +++ b/apps/server/sources/app/features/authFeature.feat.auth.methods.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAuthFeature } from "./authFeature"; + +function getMethod(feature: any, id: string): any | null { + const methods = feature?.capabilities?.auth?.methods; + if (!Array.isArray(methods)) return null; + const normalized = id.toLowerCase(); + return methods.find((m) => String(m?.id ?? "").toLowerCase() === normalized) ?? null; +} + +describe("resolveAuthFeature (auth.methods)", () => { + it("exposes key_challenge + mtls as auth methods when enabled", () => { + const feature = resolveAuthFeature({ + AUTH_ANONYMOUS_SIGNUP_ENABLED: "1", + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + } as NodeJS.ProcessEnv); + + const keyChallenge = getMethod(feature, "key_challenge"); + expect(keyChallenge).toMatchObject({ id: "key_challenge" }); + expect(keyChallenge.actions).toEqual( + expect.arrayContaining([ + { id: "login", enabled: true, mode: "keyed" }, + { id: "provision", enabled: true, mode: "keyed" }, + ]), + ); + + const mtls = getMethod(feature, "mtls"); + expect(mtls).toMatchObject({ id: "mtls" }); + expect(mtls.actions).toEqual( + expect.arrayContaining([{ id: "login", enabled: true, mode: "keyless" }]), + ); + }); + + it("disables key_challenge provisioning when key-challenge login is disabled", () => { + const feature = resolveAuthFeature({ + AUTH_ANONYMOUS_SIGNUP_ENABLED: "1", + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + } as NodeJS.ProcessEnv); + + const keyChallenge = getMethod(feature, "key_challenge"); + expect(keyChallenge).toMatchObject({ id: "key_challenge" }); + expect(keyChallenge.actions).toEqual( + expect.arrayContaining([{ id: "provision", enabled: false, mode: "keyed" }]), + ); + const signupMethods = feature?.capabilities?.auth?.signup?.methods ?? []; + const anonymous = signupMethods.find((m: any) => String(m?.id ?? "").toLowerCase() === "anonymous") ?? null; + expect(anonymous?.enabled).toBe(false); + }); + + it("disables mTLS provisioning when keyless auto-provision eligibility is not satisfied", () => { + const feature = resolveAuthFeature({ + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: "1", + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "required_e2ee", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "e2ee", + } as NodeJS.ProcessEnv); + + const mtls = getMethod(feature, "mtls"); + expect(mtls).toMatchObject({ id: "mtls" }); + expect(mtls.actions).toEqual( + expect.arrayContaining([{ id: "provision", enabled: false, mode: "keyless" }]), + ); + }); + + it("reports a misconfig when mTLS is enabled but keyless accounts are unavailable due to required_e2ee storage policy", () => { + const feature = resolveAuthFeature({ + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "required_e2ee", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "e2ee", + } as NodeJS.ProcessEnv); + + const misconfig = feature?.capabilities?.auth?.misconfig ?? []; + expect(misconfig).toEqual(expect.arrayContaining([expect.objectContaining({ code: "auth_mtls_keyless_unavailable" })])); + }); + + it("disables keyless OAuth provisioning when keyless auto-provision eligibility is not satisfied", () => { + const feature = resolveAuthFeature({ + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: "1", + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: "optional", + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: "e2ee", + HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS: "github", + HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_AUTO_PROVISION: "1", + GITHUB_CLIENT_ID: "id", + GITHUB_CLIENT_SECRET: "secret", + GITHUB_REDIRECT_URL: "https://example.test/v1/oauth/github/callback", + } as NodeJS.ProcessEnv); + + const github = getMethod(feature, "github"); + expect(github).toMatchObject({ id: "github" }); + expect(github.actions).toEqual( + expect.arrayContaining([{ id: "provision", enabled: false, mode: "keyless" }]), + ); + expect(github.actions).toEqual( + expect.arrayContaining([{ id: "login", enabled: true, mode: "keyless" }]), + ); + }); +}); diff --git a/apps/server/sources/app/features/authFeature.feat.auth.mtls.autoRedirect.spec.ts b/apps/server/sources/app/features/authFeature.feat.auth.mtls.autoRedirect.spec.ts new file mode 100644 index 000000000..64c961662 --- /dev/null +++ b/apps/server/sources/app/features/authFeature.feat.auth.mtls.autoRedirect.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAuthFeature } from "./authFeature"; + +describe("resolveAuthFeature (mTLS auto-redirect)", () => { + it('allows auth.ui.autoRedirect.providerId="mtls" when mTLS login is viable', () => { + const feature = resolveAuthFeature({ + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED: "1", + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_PROVIDER_ID: "mtls", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "1", + } as NodeJS.ProcessEnv); + + expect(feature.capabilities?.auth?.ui?.autoRedirect?.enabled).toBe(true); + expect(feature.capabilities?.auth?.ui?.autoRedirect?.providerId).toBe("mtls"); + }); + + it('refuses auth.ui.autoRedirect.providerId="mtls" when mTLS login is not viable', () => { + const feature = resolveAuthFeature({ + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED: "1", + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_PROVIDER_ID: "mtls", + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: "1", + HAPPIER_FEATURE_AUTH_MTLS__MODE: "forwarded", + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: "0", + } as NodeJS.ProcessEnv); + + expect(feature.capabilities?.auth?.ui?.autoRedirect?.enabled).toBe(true); + expect(feature.capabilities?.auth?.ui?.autoRedirect?.providerId).toBe(null); + }); +}); + diff --git a/apps/server/sources/app/features/authFeature.feat.auth.oauthKeyless.autoRedirect.spec.ts b/apps/server/sources/app/features/authFeature.feat.auth.oauthKeyless.autoRedirect.spec.ts new file mode 100644 index 000000000..b681235b1 --- /dev/null +++ b/apps/server/sources/app/features/authFeature.feat.auth.oauthKeyless.autoRedirect.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAuthFeature } from "./authFeature"; + +describe("resolveAuthFeature (OAuth keyless auto-redirect)", () => { + it("auto-selects the sole enabled keyless OAuth login method when anonymous signup is disabled", () => { + const feature = resolveAuthFeature({ + AUTH_ANONYMOUS_SIGNUP_ENABLED: "0", + AUTH_SIGNUP_PROVIDERS: "", + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED: "1", + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: "0", + HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED: "1", + HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS: "github", + GITHUB_CLIENT_ID: "id", + GITHUB_CLIENT_SECRET: "secret", + GITHUB_REDIRECT_URL: "https://example.test/v1/oauth/github/callback", + } as NodeJS.ProcessEnv); + + expect(feature.capabilities?.auth?.ui?.autoRedirect?.enabled).toBe(true); + expect(feature.capabilities?.auth?.ui?.autoRedirect?.providerId).toBe("github"); + }); +}); diff --git a/apps/server/sources/app/features/authFeature.ts b/apps/server/sources/app/features/authFeature.ts index 51bad1475..aebf92e27 100644 --- a/apps/server/sources/app/features/authFeature.ts +++ b/apps/server/sources/app/features/authFeature.ts @@ -1,7 +1,12 @@ import type { FeaturesPayloadDelta, FeaturesResponse } from "./types"; import { resolveAuthPolicyFromEnv } from "@/app/auth/authPolicy"; import { resolveAuthProviderRegistryResult } from "@/app/auth/providers/registry"; -import { readAuthFeatureEnv } from "./catalog/readFeatureEnv"; +import { readAuthFeatureEnv, readAuthMtlsFeatureEnv } from "./catalog/readFeatureEnv"; +import { readAuthOauthKeylessFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { resolveAuthMethodRegistry } from "@/app/auth/methods/registry"; +import { resolveKeylessAccountsEnabled } from "@/app/features/e2ee/resolveKeylessAccountsEnabled"; +import { resolveKeylessAutoProvisionEligibility } from "@/app/auth/keyless/resolveKeylessAutoProvisionEligibility"; +import { resolveKeylessAccountsAvailability } from "@/app/features/e2ee/resolveKeylessAccountsEnabled"; function uniqueStrings(values: readonly string[]): string[] { const seen = new Set<string>(); @@ -18,18 +23,29 @@ function uniqueStrings(values: readonly string[]): string[] { export function resolveAuthFeature(env: NodeJS.ProcessEnv): FeaturesPayloadDelta { const featureEnv = readAuthFeatureEnv(env); + const mtlsEnv = readAuthMtlsFeatureEnv(env); const policy = resolveAuthPolicyFromEnv(env); const authProviderRegistryResult = resolveAuthProviderRegistryResult(env); const authProviderRegistry = authProviderRegistryResult.providers; + const oauthKeylessEnv = readAuthOauthKeylessFeatureEnv(env); + const keylessAccountsEnabled = resolveKeylessAccountsEnabled(env); + const keylessAutoProvisionEligible = resolveKeylessAutoProvisionEligibility(env).ok; + + const methodRegistry = resolveAuthMethodRegistry(env); + const coreAuthMethods = methodRegistry.map((m) => m.resolveAuthMethod({ env, policy })); + const keyChallengeMethod = + coreAuthMethods.find((m) => String(m?.id ?? "").trim().toLowerCase() === "key_challenge") ?? null; + const keyChallengeLoginEnabled = + keyChallengeMethod?.actions?.some((a: any) => a?.id === "login" && a?.enabled === true) === true; + const keyChallengeProvisionEnabled = + keyChallengeMethod?.actions?.some((a: any) => a?.id === "provision" && a?.enabled === true) === true; + + const mtlsMethod = coreAuthMethods.find((m) => String(m?.id ?? "").trim().toLowerCase() === "mtls") ?? null; + const mtlsGateEnabled = mtlsMethod?.actions?.some((a: any) => a?.id === "login" && a?.enabled === true) === true; const signupProviders = uniqueStrings(policy.signupProviders); const requiredLoginProviders = uniqueStrings(policy.requiredLoginProviders); - const signupMethods: Array<{ id: string; enabled: boolean }> = [ - { id: "anonymous", enabled: policy.anonymousSignupEnabled }, - ...signupProviders.map((id) => ({ id, enabled: true })), - ]; - const misconfig: FeaturesResponse["capabilities"]["auth"]["misconfig"] = []; for (const err of authProviderRegistryResult.errors) { misconfig.push({ @@ -61,11 +77,99 @@ export function resolveAuthFeature(env: NodeJS.ProcessEnv): FeaturesPayloadDelta } } + if (mtlsEnv.enabled && !mtlsGateEnabled) { + if (mtlsEnv.mode === "direct") { + misconfig.push({ + code: "auth_mtls_not_configured", + message: + "mTLS is enabled but direct mode is not supported yet. Use forwarded mode with trusted identity headers.", + kind: "auth-mtls-config", + envVars: ["HAPPIER_FEATURE_AUTH_MTLS__ENABLED", "HAPPIER_FEATURE_AUTH_MTLS__MODE"], + }); + } else if (!mtlsEnv.trustForwardedHeaders) { + misconfig.push({ + code: "auth_mtls_not_configured", + message: + "mTLS is enabled but forwarded mode is not configured. Set HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS=1 and configure forwarded identity headers at the edge.", + kind: "auth-mtls-config", + envVars: [ + "HAPPIER_FEATURE_AUTH_MTLS__ENABLED", + "HAPPIER_FEATURE_AUTH_MTLS__MODE", + "HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS", + ], + }); + } else { + const availability = resolveKeylessAccountsAvailability(env); + if (!availability.ok) { + misconfig.push({ + code: "auth_mtls_keyless_unavailable", + message: + availability.reason === "e2ee-required" + ? "mTLS is enabled, but keyless accounts are unavailable because the server storage policy requires E2EE. Set HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY=optional|plaintext_only and HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE=plain." + : "mTLS is enabled, but keyless accounts are disabled. Enable HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED=1 and ensure plaintext storage is allowed.", + kind: "auth-mtls-keyless", + envVars: [ + "HAPPIER_FEATURE_AUTH_MTLS__ENABLED", + "HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED", + "HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY", + "HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE", + ], + }); + } + } + } + const providers: FeaturesResponse["capabilities"]["auth"]["providers"] = {}; for (const provider of authProviderRegistry) { providers[provider.id] = provider.resolveFeatures({ env, policy }); } + const authMethods: FeaturesResponse["capabilities"]["auth"]["methods"] = [ + ...coreAuthMethods, + ...Object.entries(providers) + .map(([id, details]) => ({ + id, + actions: ((): Array<{ id: "login" | "provision" | "connect"; enabled: boolean; mode: "keyed" | "keyless" | "either" }> => { + const configured = details.configured === true; + const connectEnabled = Boolean(details.enabled) && configured; + const keyedProvisionEnabled = Boolean(details.enabled) && configured && signupProviders.includes(id); + const keylessLoginEnabled = + keylessAccountsEnabled && + configured && + oauthKeylessEnv.enabled && + oauthKeylessEnv.providers.includes(id.toLowerCase()); + const keylessProvisionEnabled = + keylessLoginEnabled && oauthKeylessEnv.autoProvision && keylessAutoProvisionEligible; + return [ + { id: "connect", enabled: connectEnabled, mode: "either" }, + { id: "provision", enabled: keyedProvisionEnabled, mode: "keyed" }, + { id: "login", enabled: keylessLoginEnabled, mode: "keyless" }, + { id: "provision", enabled: keylessProvisionEnabled, mode: "keyless" }, + ]; + })(), + ui: details.ui?.displayName ? { displayName: details.ui.displayName, iconHint: details.ui.iconHint ?? null } : undefined, + })) + .sort((a, b) => String(a.id).localeCompare(String(b.id))), + ]; + + const signupMethods: Array<{ id: string; enabled: boolean }> = [ + // Back-compat: "anonymous" maps to key_challenge provisioning. + { id: "anonymous", enabled: keyChallengeProvisionEnabled }, + ...authMethods + .filter((m) => { + const id = String(m?.id ?? "").trim().toLowerCase(); + if (!id || id === "key_challenge") return false; + const actions = Array.isArray(m?.actions) ? m.actions : []; + return actions.some((a: any) => a?.id === "provision" && a?.enabled === true && (a?.mode === "keyed" || a?.mode === "either")); + }) + .map((m) => ({ id: String(m.id).trim().toLowerCase(), enabled: true })), + ]; + + const loginMethods: Array<{ id: string; enabled: boolean }> = [ + { id: "key_challenge", enabled: keyChallengeLoginEnabled }, + { id: "mtls", enabled: mtlsGateEnabled }, + ]; + const autoRedirectEnabled = featureEnv.uiAutoRedirectEnabled; const recoveryKeyReminderEnabled = featureEnv.uiRecoveryKeyReminderEnabled; const explicitAutoRedirectProviderId = featureEnv.uiAutoRedirectProviderId; @@ -86,15 +190,32 @@ export function resolveAuthFeature(env: NodeJS.ProcessEnv): FeaturesPayloadDelta const providerResetEnabled = providerResetFlag && providerResetProviders.length > 0; let autoRedirectProviderId: string | null = null; - if (autoRedirectEnabled && !policy.anonymousSignupEnabled) { + if (autoRedirectEnabled && !keyChallengeProvisionEnabled) { + const authMethodCandidates = authMethods + .filter((m) => { + const id = String(m.id ?? "").trim().toLowerCase(); + if (!id) return false; + if (id === "key_challenge") return false; + return Array.isArray(m.actions) && m.actions.some((a) => a?.enabled === true && (a?.id === "login" || a?.id === "provision")); + }) + .map((m) => String(m.id).trim().toLowerCase()); + const candidate = explicitAutoRedirectProviderId || - (enabledExternalSignupProviders.length === 1 ? enabledExternalSignupProviders[0] : ""); + (enabledExternalSignupProviders.length === 1 + ? enabledExternalSignupProviders[0] + : enabledExternalSignupProviders.length === 0 && authMethodCandidates.length === 1 + ? authMethodCandidates[0]! + : ""); if (candidate) { - const resolver = authProviderRegistry.find((p) => p.id === candidate) ?? null; - if (resolver && (!resolver.requiresOAuth || resolver.isConfigured(env))) { - autoRedirectProviderId = resolver.id; + const method = authMethods.find((m) => String(m?.id ?? "").trim().toLowerCase() === candidate) ?? null; + const hasEnabledAuthAction = + method?.actions?.some((a: any) => a?.enabled === true && (a?.id === "login" || a?.id === "provision")) === true; + if (!hasEnabledAuthAction) { + autoRedirectProviderId = null; + } else { + autoRedirectProviderId = candidate; } } } @@ -102,11 +223,19 @@ export function resolveAuthFeature(env: NodeJS.ProcessEnv): FeaturesPayloadDelta return { features: { auth: { + mtls: { + enabled: mtlsGateEnabled, + }, recovery: { providerReset: { enabled: providerResetEnabled, }, }, + login: { + keyChallenge: { + enabled: keyChallengeLoginEnabled, + }, + }, ui: { recoveryKeyReminder: { enabled: recoveryKeyReminderEnabled, @@ -116,13 +245,30 @@ export function resolveAuthFeature(env: NodeJS.ProcessEnv): FeaturesPayloadDelta }, capabilities: { auth: { + methods: authMethods, signup: { methods: signupMethods }, - login: { requiredProviders: requiredLoginProviders }, + login: { methods: loginMethods, requiredProviders: requiredLoginProviders }, recovery: { providerReset: { providers: providerResetEnabled ? providerResetProviders : [], }, }, + mtls: { + mode: mtlsEnv.mode, + autoProvision: mtlsEnv.autoProvision, + identitySource: mtlsEnv.identitySource, + policy: { + trustForwardedHeaders: mtlsEnv.trustForwardedHeaders, + issuerAllowlist: { + enabled: mtlsEnv.allowedIssuers.length > 0, + count: mtlsEnv.allowedIssuers.length, + }, + emailDomainAllowlist: { + enabled: mtlsEnv.allowedEmailDomains.length > 0, + count: mtlsEnv.allowedEmailDomains.length, + }, + }, + }, ui: { autoRedirect: { enabled: autoRedirectEnabled, diff --git a/apps/server/sources/app/features/catalog/featureEnvSchema.ts b/apps/server/sources/app/features/catalog/featureEnvSchema.ts index 323f8daf2..1da081382 100644 --- a/apps/server/sources/app/features/catalog/featureEnvSchema.ts +++ b/apps/server/sources/app/features/catalog/featureEnvSchema.ts @@ -24,9 +24,34 @@ export const FEATURE_ENV_KEYS = Object.freeze({ socialFriendsIdentityProvider: 'HAPPIER_FEATURE_SOCIAL_FRIENDS__IDENTITY_PROVIDER', authRecoveryProviderResetEnabled: 'HAPPIER_FEATURE_AUTH_RECOVERY__PROVIDER_RESET_ENABLED', + authLoginKeyChallengeEnabled: 'HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED', authUiAutoRedirectEnabled: 'HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED', authUiAutoRedirectProviderId: 'HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_PROVIDER_ID', authUiRecoveryKeyReminderEnabled: 'HAPPIER_FEATURE_AUTH_UI__RECOVERY_KEY_REMINDER_ENABLED', + authMtlsEnabled: 'HAPPIER_FEATURE_AUTH_MTLS__ENABLED', + authMtlsMode: 'HAPPIER_FEATURE_AUTH_MTLS__MODE', + authMtlsAutoProvision: 'HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION', + authMtlsTrustForwardedHeaders: 'HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS', + authMtlsIdentitySource: 'HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE', + authMtlsAllowedEmailDomains: 'HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS', + authMtlsAllowedIssuers: 'HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS', + authMtlsForwardedEmailHeader: 'HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER', + authMtlsForwardedUpnHeader: 'HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_UPN_HEADER', + authMtlsForwardedSubjectHeader: 'HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_SUBJECT_HEADER', + authMtlsForwardedFingerprintHeader: 'HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_FINGERPRINT_HEADER', + authMtlsForwardedIssuerHeader: 'HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_ISSUER_HEADER', + authMtlsReturnToAllowPrefixes: 'HAPPIER_FEATURE_AUTH_MTLS__RETURN_TO_ALLOW_PREFIXES', + authMtlsClaimTtlSeconds: 'HAPPIER_FEATURE_AUTH_MTLS__CLAIM_TTL_SECONDS', + + authOauthKeylessEnabled: 'HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED', + authOauthKeylessProviders: 'HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS', + authOauthKeylessAutoProvision: 'HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_AUTO_PROVISION', + + encryptionStoragePolicy: 'HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY', + encryptionAllowAccountOptOut: 'HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT', + encryptionDefaultAccountMode: 'HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE', + + e2eeKeylessAccountsEnabled: 'HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED', }); export type FeatureEnvKey = (typeof FEATURE_ENV_KEYS)[keyof typeof FEATURE_ENV_KEYS]; diff --git a/apps/server/sources/app/features/catalog/readFeatureEnv.ts b/apps/server/sources/app/features/catalog/readFeatureEnv.ts index 7535b6aa6..9d5e94188 100644 --- a/apps/server/sources/app/features/catalog/readFeatureEnv.ts +++ b/apps/server/sources/app/features/catalog/readFeatureEnv.ts @@ -41,11 +41,120 @@ export type SocialFriendsFeatureEnv = Readonly<{ export type AuthFeatureEnv = Readonly<{ recoveryProviderResetEnabled: boolean; + loginKeyChallengeEnabled: boolean; uiAutoRedirectEnabled: boolean; uiAutoRedirectProviderId: string; uiRecoveryKeyReminderEnabled: boolean; }>; +export type AuthMtlsIdentitySource = "san_email" | "san_upn" | "subject_cn" | "fingerprint"; +export type AuthMtlsFeatureEnv = Readonly<{ + enabled: boolean; + mode: "forwarded" | "direct"; + autoProvision: boolean; + trustForwardedHeaders: boolean; + identitySource: AuthMtlsIdentitySource; + allowedEmailDomains: readonly string[]; + allowedIssuers: readonly string[]; + forwardedEmailHeader: string; + forwardedUpnHeader: string; + forwardedSubjectHeader: string; + forwardedFingerprintHeader: string; + forwardedIssuerHeader: string; + returnToAllowPrefixes: readonly string[]; + claimTtlSeconds: number; +}>; + +export type AuthOauthKeylessFeatureEnv = Readonly<{ + enabled: boolean; + providers: readonly string[]; + autoProvision: boolean; +}>; + +export type EncryptionFeatureEnv = Readonly<{ + storagePolicy: "required_e2ee" | "optional" | "plaintext_only"; + allowAccountOptOut: boolean; + defaultAccountMode: "e2ee" | "plain"; +}>; + +export type E2eeFeatureEnv = Readonly<{ + keylessAccountsEnabled: boolean; +}>; + +function parseCsvList(raw: string | undefined): string[] { + if (typeof raw !== "string") return []; + return raw + .split(/[,\s]+/g) + .map((s) => s.trim()) + .filter(Boolean); +} + +function parseCommaList(raw: string | undefined): string[] { + if (typeof raw !== "string") return []; + return raw + .split(/[,\n]+/g) + .map((s) => s.trim()) + .filter(Boolean); +} + +function normalizeIssuerDnLower(raw: string): string { + return raw + .trim() + .replace(/\s+/g, " ") + .toLowerCase(); +} + +function extractIssuerCommonName(normalizedDnLower: string): string | null { + const match = normalizedDnLower.match(/(?:^|,|\/)\s*cn\s*=\s*([^,\/]+)\s*(?:,|\/|$)/i); + const cn = match?.[1]?.trim() ?? ""; + return cn || null; +} + +function countRdnAssignments(normalizedDnLower: string): number { + const matches = normalizedDnLower.match(/\b[a-z][a-z0-9-]*\s*=/g); + return matches?.length ?? 0; +} + +// Normalizes issuer allowlist entries into one of: +// - "cn=..." for CN-only matching +// - "dn=..." for exact DN matching (normalized) +export function normalizeAuthMtlsIssuerValue(raw: string): string { + const normalized = normalizeIssuerDnLower(raw); + if (!normalized) return ""; + + // Ergonomic CN-only entries (either bare strings or "cn=..."). + const rdnCount = countRdnAssignments(normalized); + const cn = extractIssuerCommonName(normalized); + if (!normalized.includes("=")) { + return `cn=${normalized}`; + } + if (rdnCount <= 1 && cn) { + return `cn=${cn}`; + } + + // Treat as an exact DN entry. + return `dn=${normalized}`; +} + +function parseIssuerAllowlist(raw: string | undefined): string[] { + if (typeof raw !== "string") return []; + const trimmed = raw.trim(); + if (!trimmed) return []; + + // DN strings commonly contain commas; avoid splitting on commas when the input looks DN-shaped. + if (trimmed.includes("=")) { + // Allow multiple DN entries via newline or semicolon separation. + return trimmed + .split(/[;\n]+/g) + .map((s) => s.trim()) + .filter(Boolean); + } + + // Otherwise treat as a standard CSV/whitespace list of CN values. + // CN strings commonly include spaces; split on comma/newline only. + return parseCommaList(trimmed); +} + export function readAutomationsFeatureEnv(env: NodeJS.ProcessEnv): AutomationsFeatureEnv { return { enabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.automationsEnabled], true), @@ -113,8 +222,111 @@ export function readSocialFriendsFeatureEnv(env: NodeJS.ProcessEnv): SocialFrien export function readAuthFeatureEnv(env: NodeJS.ProcessEnv): AuthFeatureEnv { return { recoveryProviderResetEnabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.authRecoveryProviderResetEnabled], true), + loginKeyChallengeEnabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.authLoginKeyChallengeEnabled], true), uiAutoRedirectEnabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.authUiAutoRedirectEnabled], false), uiAutoRedirectProviderId: (env[FEATURE_ENV_KEYS.authUiAutoRedirectProviderId] ?? '').trim().toLowerCase(), uiRecoveryKeyReminderEnabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.authUiRecoveryKeyReminderEnabled], true), }; } + +export function readAuthMtlsFeatureEnv(env: NodeJS.ProcessEnv): AuthMtlsFeatureEnv { + const enabled = parseBooleanEnv(env[FEATURE_ENV_KEYS.authMtlsEnabled], false); + const rawMode = (env[FEATURE_ENV_KEYS.authMtlsMode] ?? "").toString().trim().toLowerCase(); + const mode: AuthMtlsFeatureEnv["mode"] = rawMode === "direct" ? "direct" : "forwarded"; + + const autoProvision = parseBooleanEnv(env[FEATURE_ENV_KEYS.authMtlsAutoProvision], false); + const trustForwardedHeaders = parseBooleanEnv(env[FEATURE_ENV_KEYS.authMtlsTrustForwardedHeaders], false); + + const rawIdentitySource = (env[FEATURE_ENV_KEYS.authMtlsIdentitySource] ?? "").toString().trim().toLowerCase(); + const identitySource: AuthMtlsFeatureEnv["identitySource"] = + rawIdentitySource === "san_upn" || rawIdentitySource === "subject_cn" || rawIdentitySource === "fingerprint" || rawIdentitySource === "san_email" + ? (rawIdentitySource as AuthMtlsFeatureEnv["identitySource"]) + : "san_email"; + + const allowedEmailDomains = Object.freeze(parseCsvList(env[FEATURE_ENV_KEYS.authMtlsAllowedEmailDomains]).map((s) => s.toLowerCase())); + const allowedIssuers = Object.freeze(parseIssuerAllowlist(env[FEATURE_ENV_KEYS.authMtlsAllowedIssuers]).map(normalizeAuthMtlsIssuerValue).filter(Boolean)); + + const forwardedEmailHeader = (env[FEATURE_ENV_KEYS.authMtlsForwardedEmailHeader] ?? "x-happier-client-cert-email") + .toString() + .trim() + .toLowerCase(); + const forwardedUpnHeader = (env[FEATURE_ENV_KEYS.authMtlsForwardedUpnHeader] ?? "x-happier-client-cert-upn") + .toString() + .trim() + .toLowerCase(); + const forwardedSubjectHeader = (env[FEATURE_ENV_KEYS.authMtlsForwardedSubjectHeader] ?? "x-happier-client-cert-subject") + .toString() + .trim() + .toLowerCase(); + const forwardedFingerprintHeader = (env[FEATURE_ENV_KEYS.authMtlsForwardedFingerprintHeader] ?? "x-happier-client-cert-sha256") + .toString() + .trim() + .toLowerCase(); + const forwardedIssuerHeader = (env[FEATURE_ENV_KEYS.authMtlsForwardedIssuerHeader] ?? "x-happier-client-cert-issuer") + .toString() + .trim() + .toLowerCase(); + + const allowPrefixesFromEnv = parseCsvList(env[FEATURE_ENV_KEYS.authMtlsReturnToAllowPrefixes]); + const webUrl = (env.HAPPIER_WEBAPP_URL ?? env.HAPPY_WEBAPP_URL ?? "https://app.happier.dev").toString().trim(); + const returnToAllowPrefixes = Object.freeze( + (allowPrefixesFromEnv.length > 0 ? allowPrefixesFromEnv : ["happier://", webUrl]) + .map((s) => s.trim()) + .filter(Boolean), + ); + + const claimTtlSeconds = parseIntEnv(env[FEATURE_ENV_KEYS.authMtlsClaimTtlSeconds], 60, { min: 10, max: 3600 }); + + return { + enabled, + mode, + autoProvision, + trustForwardedHeaders, + identitySource, + allowedEmailDomains, + allowedIssuers, + forwardedEmailHeader, + forwardedUpnHeader, + forwardedSubjectHeader, + forwardedFingerprintHeader, + forwardedIssuerHeader, + returnToAllowPrefixes, + claimTtlSeconds, + }; +} + +export function readAuthOauthKeylessFeatureEnv(env: NodeJS.ProcessEnv): AuthOauthKeylessFeatureEnv { + const enabled = parseBooleanEnv(env[FEATURE_ENV_KEYS.authOauthKeylessEnabled], false); + const providers = Object.freeze(parseCsvList(env[FEATURE_ENV_KEYS.authOauthKeylessProviders]).map((s) => s.toLowerCase())); + const autoProvision = parseBooleanEnv(env[FEATURE_ENV_KEYS.authOauthKeylessAutoProvision], false); + return { + enabled, + providers, + autoProvision, + }; +} + +export function readEncryptionFeatureEnv(env: NodeJS.ProcessEnv): EncryptionFeatureEnv { + const rawStoragePolicy = (env[FEATURE_ENV_KEYS.encryptionStoragePolicy] ?? "").toString().trim(); + const storagePolicy: EncryptionFeatureEnv["storagePolicy"] = + rawStoragePolicy === "optional" || rawStoragePolicy === "plaintext_only" || rawStoragePolicy === "required_e2ee" + ? rawStoragePolicy + : "required_e2ee"; + + const allowAccountOptOut = parseBooleanEnv(env[FEATURE_ENV_KEYS.encryptionAllowAccountOptOut], false); + const rawDefaultAccountMode = (env[FEATURE_ENV_KEYS.encryptionDefaultAccountMode] ?? "").toString().trim(); + const defaultAccountMode: EncryptionFeatureEnv["defaultAccountMode"] = + rawDefaultAccountMode === "plain" || rawDefaultAccountMode === "e2ee" ? rawDefaultAccountMode : "e2ee"; + + return { + storagePolicy, + allowAccountOptOut, + defaultAccountMode, + }; +} + +export function readE2eeFeatureEnv(env: NodeJS.ProcessEnv): E2eeFeatureEnv { + return { + keylessAccountsEnabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.e2eeKeylessAccountsEnabled], true), + }; +} diff --git a/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts b/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts index 236fe1a54..2487fd431 100644 --- a/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts +++ b/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts @@ -10,6 +10,8 @@ import { resolveAuthFeature } from '@/app/features/authFeature'; import { resolveConnectedServicesFeature } from '@/app/features/connectedServicesFeature'; import { resolveUpdatesFeature } from '@/app/features/updatesFeature'; import { resolveAttachmentsUploadsFeature } from '@/app/features/attachmentsUploadsFeature'; +import { resolveEncryptionFeature } from '@/app/features/encryptionFeature'; +import { resolveE2eeFeature } from '@/app/features/e2eeFeature'; export type ServerFeatureResolver = (env: NodeJS.ProcessEnv) => FeaturesPayloadDelta; @@ -24,4 +26,6 @@ export const serverFeatureRegistry: readonly ServerFeatureResolver[] = Object.fr (env) => resolveFriendsFeature(env), (env) => resolveOAuthFeature(env), (env) => resolveAuthFeature(env), + (env) => resolveEncryptionFeature(env), + (env) => resolveE2eeFeature(env), ]); diff --git a/apps/server/sources/app/features/e2ee/resolveKeylessAccountsEnabled.ts b/apps/server/sources/app/features/e2ee/resolveKeylessAccountsEnabled.ts new file mode 100644 index 000000000..72a570ec1 --- /dev/null +++ b/apps/server/sources/app/features/e2ee/resolveKeylessAccountsEnabled.ts @@ -0,0 +1,21 @@ +import { readE2eeFeatureEnv, readEncryptionFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; + +export type KeylessAccountsAvailability = + | Readonly<{ ok: true }> + | Readonly<{ ok: false; reason: "keyless-disabled" | "e2ee-required" }>; + +export function resolveKeylessAccountsAvailability(env: NodeJS.ProcessEnv): KeylessAccountsAvailability { + const e2eeEnv = readE2eeFeatureEnv(env); + if (!e2eeEnv.keylessAccountsEnabled) { + return { ok: false, reason: "keyless-disabled" }; + } + const encryptionEnv = readEncryptionFeatureEnv(env); + if (encryptionEnv.storagePolicy === "required_e2ee") { + return { ok: false, reason: "e2ee-required" }; + } + return { ok: true }; +} + +export function resolveKeylessAccountsEnabled(env: NodeJS.ProcessEnv): boolean { + return resolveKeylessAccountsAvailability(env).ok; +} diff --git a/apps/server/sources/app/features/e2eeFeature.ts b/apps/server/sources/app/features/e2eeFeature.ts new file mode 100644 index 000000000..b7b18080b --- /dev/null +++ b/apps/server/sources/app/features/e2eeFeature.ts @@ -0,0 +1,15 @@ +import type { FeaturesPayloadDelta } from "@/app/features/types"; + +import { resolveKeylessAccountsEnabled } from "@/app/features/e2ee/resolveKeylessAccountsEnabled"; + +export function resolveE2eeFeature(env: NodeJS.ProcessEnv): FeaturesPayloadDelta { + return { + features: { + e2ee: { + keylessAccounts: { + enabled: resolveKeylessAccountsEnabled(env), + }, + }, + }, + }; +} diff --git a/apps/server/sources/app/features/encryptionFeature.ts b/apps/server/sources/app/features/encryptionFeature.ts new file mode 100644 index 000000000..a0ffa4950 --- /dev/null +++ b/apps/server/sources/app/features/encryptionFeature.ts @@ -0,0 +1,33 @@ +import type { FeaturesPayloadDelta } from "@/app/features/types"; + +import { readEncryptionFeatureEnv } from "./catalog/readFeatureEnv"; +import { resolveEffectiveDefaultAccountEncryptionMode } from "@happier-dev/protocol"; + +export function resolveEncryptionFeature(env: NodeJS.ProcessEnv): FeaturesPayloadDelta { + const featureEnv = readEncryptionFeatureEnv(env); + + const plaintextStorageEnabled = featureEnv.storagePolicy !== "required_e2ee"; + const accountOptOutEnabled = + plaintextStorageEnabled && featureEnv.storagePolicy === "optional" && featureEnv.allowAccountOptOut; + + const effectiveDefaultAccountMode = resolveEffectiveDefaultAccountEncryptionMode( + featureEnv.storagePolicy, + featureEnv.defaultAccountMode, + ); + + return { + features: { + encryption: { + plaintextStorage: { enabled: plaintextStorageEnabled }, + accountOptOut: { enabled: accountOptOutEnabled }, + }, + }, + capabilities: { + encryption: { + storagePolicy: featureEnv.storagePolicy, + allowAccountOptOut: accountOptOutEnabled, + defaultAccountMode: effectiveDefaultAccountMode, + }, + }, + }; +} diff --git a/apps/server/sources/app/oauth/providers/github.timeout.spec.ts b/apps/server/sources/app/oauth/providers/github.timeout.spec.ts index 1dc1ccceb..50e3fc51c 100644 --- a/apps/server/sources/app/oauth/providers/github.timeout.spec.ts +++ b/apps/server/sources/app/oauth/providers/github.timeout.spec.ts @@ -228,4 +228,56 @@ describe("githubOAuthProvider timeouts", () => { login: "alice", }); }); + + it("uses GITHUB_OAUTH_AUTHORIZE_URL when provided", async () => { + const env: NodeJS.ProcessEnv = { + GITHUB_CLIENT_ID: "cid", + GITHUB_CLIENT_SECRET: "secret", + GITHUB_REDIRECT_URL: "https://server.example.test/v1/oauth/github/callback", + GITHUB_OAUTH_AUTHORIZE_URL: "http://127.0.0.1:7777/login/oauth/authorize", + }; + + const url = await githubOAuthProvider.resolveAuthorizeUrl({ + env, + state: "state", + scope: "read:user", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + }); + + expect(url.startsWith("http://127.0.0.1:7777/login/oauth/authorize?")).toBe(true); + const parsed = new URL(url); + expect(parsed.searchParams.get("client_id")).toBe("cid"); + expect(parsed.searchParams.get("state")).toBe("state"); + expect(parsed.searchParams.get("code_challenge")).toBe("challenge"); + }); + + it("uses GITHUB_OAUTH_TOKEN_URL and GITHUB_API_USER_URL when provided", async () => { + const env: NodeJS.ProcessEnv = { + GITHUB_HTTP_TIMEOUT_SECONDS: "7", + GITHUB_CLIENT_ID: "cid", + GITHUB_CLIENT_SECRET: "secret", + GITHUB_REDIRECT_URL: "https://server.example.test/v1/oauth/github/callback", + GITHUB_OAUTH_TOKEN_URL: "http://127.0.0.1:7777/login/oauth/access_token", + GITHUB_API_USER_URL: "http://127.0.0.1:7777/user", + }; + + const fetchSpy = vi.fn(async (url: any) => { + const u = String(url); + if (u.endsWith("/login/oauth/access_token")) { + return { ok: true, json: async () => ({ access_token: "t" }) } as any; + } + if (u.endsWith("/user")) { + return { ok: true, json: async () => ({ id: 1, login: "alice" }) } as any; + } + throw new Error(`Unexpected fetch: ${u}`); + }); + vi.stubGlobal("fetch", fetchSpy as any); + + const token = await githubOAuthProvider.exchangeCodeForAccessToken({ env, code: "code" }); + expect(token).toEqual({ accessToken: "t" }); + + const profile = await githubOAuthProvider.fetchProfile({ env, accessToken: "t" }); + expect(profile).toMatchObject({ id: 1, login: "alice" }); + }); }); diff --git a/apps/server/sources/app/oauth/providers/github.ts b/apps/server/sources/app/oauth/providers/github.ts index ebfc3d3ed..1c4a6361b 100644 --- a/apps/server/sources/app/oauth/providers/github.ts +++ b/apps/server/sources/app/oauth/providers/github.ts @@ -3,6 +3,7 @@ import { resolveGitHubOAuthConfigFromEnv } from "./githubOAuthConfig"; import { GitHubProfileSchema, type GitHubProfile } from "@/app/auth/providers/github/types"; import { shouldRequestReadOrgScopeForGitHub } from "@/app/auth/providers/github/restrictions"; import { resolveGitHubHttpTimeoutMs } from "@/app/auth/providers/github/httpTimeout"; +import { isLoopbackHostname } from "@/utils/network/urlSafety"; function parseGitHubProfile(raw: unknown): GitHubProfile | null { const parsed = GitHubProfileSchema.safeParse(raw); @@ -10,6 +11,43 @@ function parseGitHubProfile(raw: unknown): GitHubProfile | null { return parsed.data; } +function resolveGitHubOverrideUrl(params: { env: NodeJS.ProcessEnv; key: string; fallback: string }): string { + const raw = params.env[params.key]?.toString?.().trim?.() ?? ""; + if (!raw) return params.fallback; + try { + const url = new URL(raw); + if (url.protocol === "https:") return url.toString(); + if (url.protocol === "http:" && isLoopbackHostname(url.hostname)) return url.toString(); + return params.fallback; + } catch { + return params.fallback; + } +} + +function resolveGitHubOAuthAuthorizeUrl(env: NodeJS.ProcessEnv): string { + return resolveGitHubOverrideUrl({ + env, + key: "GITHUB_OAUTH_AUTHORIZE_URL", + fallback: "https://github.com/login/oauth/authorize", + }); +} + +function resolveGitHubOAuthTokenUrl(env: NodeJS.ProcessEnv): string { + return resolveGitHubOverrideUrl({ + env, + key: "GITHUB_OAUTH_TOKEN_URL", + fallback: "https://github.com/login/oauth/access_token", + }); +} + +function resolveGitHubApiUserUrl(env: NodeJS.ProcessEnv): string { + return resolveGitHubOverrideUrl({ + env, + key: "GITHUB_API_USER_URL", + fallback: "https://api.github.com/user", + }); +} + async function safeReadResponseText(res: Response): Promise<string> { try { return await res.text(); @@ -88,29 +126,29 @@ export const githubOAuthProvider: OAuthFlowProvider = Object.freeze({ if (!cfg.clientId || !cfg.redirectUrl) { throw new Error("oauth_not_configured"); } - const params: Record<string, string> = { - client_id: cfg.clientId, - redirect_uri: cfg.redirectUrl, - scope, - state, - }; + const authorizeUrl = resolveGitHubOAuthAuthorizeUrl(env); + const url = new URL(authorizeUrl); + url.searchParams.set("client_id", cfg.clientId); + url.searchParams.set("redirect_uri", cfg.redirectUrl); + url.searchParams.set("scope", scope); + url.searchParams.set("state", state); if (codeChallenge && codeChallengeMethod) { - params.code_challenge = codeChallenge; - params.code_challenge_method = codeChallengeMethod; + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", codeChallengeMethod); } - const search = new URLSearchParams(params); - return `https://github.com/login/oauth/authorize?${search.toString()}`; + return url.toString(); }, exchangeCodeForAccessToken: async ({ env, code, pkceCodeVerifier }): Promise<OAuthTokenExchangeResult> => { const cfg = resolveGitHubOAuthConfigFromEnv(env); if (!cfg.clientId || !cfg.clientSecret) { throw new Error("oauth_not_configured"); } + const tokenUrl = resolveGitHubOAuthTokenUrl(env); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), resolveGitHubHttpTimeoutMs(env)); let tokenResponse: Response; try { - tokenResponse = await fetch("https://github.com/login/oauth/access_token", { + tokenResponse = await fetch(tokenUrl, { method: "POST", headers: { Accept: "application/json", @@ -153,11 +191,12 @@ export const githubOAuthProvider: OAuthFlowProvider = Object.freeze({ return { accessToken }; }, fetchProfile: async ({ env, accessToken }): Promise<unknown> => { + const userUrl = resolveGitHubApiUserUrl(env); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), resolveGitHubHttpTimeoutMs(env)); let userResponse: Response; try { - userResponse = await fetch("https://api.github.com/user", { + userResponse = await fetch(userUrl, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github.v3+json", diff --git a/apps/server/sources/app/oauth/providers/oidc/oidcOAuthProvider.ts b/apps/server/sources/app/oauth/providers/oidc/oidcOAuthProvider.ts index 698e2688c..2deaaeb2e 100644 --- a/apps/server/sources/app/oauth/providers/oidc/oidcOAuthProvider.ts +++ b/apps/server/sources/app/oauth/providers/oidc/oidcOAuthProvider.ts @@ -30,7 +30,7 @@ export function createOidcOAuthProvider(instance: OidcAuthProviderInstanceConfig }); return url.toString(); }, - exchangeCodeForAccessToken: async ({ code, state, pkceCodeVerifier, expectedNonce }): Promise<OAuthTokenExchangeResult> => { + exchangeCodeForAccessToken: async ({ code, state, iss, pkceCodeVerifier, expectedNonce }): Promise<OAuthTokenExchangeResult> => { if (!instance.clientId || !instance.clientSecret || !instance.redirectUrl || !instance.issuer) { throw new Error("oauth_not_configured"); } @@ -40,6 +40,9 @@ export function createOidcOAuthProvider(instance: OidcAuthProviderInstanceConfig if (typeof state === "string" && state) { callbackUrl.searchParams.set("state", state); } + if (typeof iss === "string" && iss) { + callbackUrl.searchParams.set("iss", iss); + } const tokens = await oidcClient.authorizationCodeGrant(cfg, callbackUrl, { ...(typeof expectedNonce === "string" && expectedNonce ? { expectedNonce } : {}), diff --git a/apps/server/sources/app/oauth/providers/types.ts b/apps/server/sources/app/oauth/providers/types.ts index d3fbb721f..460736073 100644 --- a/apps/server/sources/app/oauth/providers/types.ts +++ b/apps/server/sources/app/oauth/providers/types.ts @@ -28,6 +28,7 @@ export type OAuthFlowProvider = Readonly<{ env: NodeJS.ProcessEnv; code: string; state?: string; + iss?: string; pkceCodeVerifier?: string; expectedNonce?: string; }) => Promise<OAuthTokenExchangeResult>; diff --git a/apps/server/sources/app/presence/sessionCache.sessionPresence.spec.ts b/apps/server/sources/app/presence/sessionCache.sessionPresence.spec.ts new file mode 100644 index 000000000..cb3920fef --- /dev/null +++ b/apps/server/sources/app/presence/sessionCache.sessionPresence.spec.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/utils/logging/log", () => ({ log: vi.fn() })); + +vi.mock("@/app/monitoring/metrics2", () => ({ + sessionCacheCounter: { inc: vi.fn() }, + databaseUpdatesSkippedCounter: { inc: vi.fn() }, +})); + +vi.mock("@/app/share/accessControl", () => ({ + checkSessionAccess: vi.fn(async () => ({ + userId: "u1", + sessionId: "s1", + level: "owner", + isOwner: true, + })), +})); + +let sessionLastActiveAtMs = 0; +let sessionActive = false; +const sessionFindUnique = vi.fn(async () => ({ + id: "s1", + lastActiveAt: new Date(sessionLastActiveAtMs), + active: sessionActive, +})); +const sessionUpdateMany = vi.fn(async () => ({ count: 1 })); + +vi.mock("@/storage/db", () => ({ + db: { + session: { + findUnique: sessionFindUnique, + updateMany: sessionUpdateMany, + }, + machine: { + findUnique: vi.fn(), + updateMany: vi.fn(), + }, + }, +})); + +describe("ActivityCache session presence", () => { + let activityCache: any | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + sessionLastActiveAtMs = Date.now(); + sessionActive = false; + }); + + afterEach(() => { + activityCache?.shutdown?.(); + activityCache = null; + vi.useRealTimers(); + }); + + it("forces a DB write to set session.active=true even when lastActiveAt is already recent", async () => { + ({ activityCache } = await import("./sessionCache")); + activityCache.enableDbFlush(); + + const ok = await activityCache.isSessionValid("s1", "u1"); + expect(ok).toBe(true); + + const queued = activityCache.queueSessionUpdate("s1", "u1", Date.now()); + expect(queued).toBe(true); + + await (activityCache as any).flushPendingUpdates(); + + expect(sessionUpdateMany).toHaveBeenCalledTimes(1); + expect(sessionUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: "s1" }), + data: expect.objectContaining({ active: true, lastActiveAt: expect.any(Date) }), + }), + ); + + const queuedAgain = activityCache.queueSessionUpdate("s1", "u1", Date.now()); + expect(queuedAgain).toBe(false); + }); +}); + diff --git a/apps/server/sources/app/presence/sessionCache.ts b/apps/server/sources/app/presence/sessionCache.ts index 05cd98f17..4e0a00412 100644 --- a/apps/server/sources/app/presence/sessionCache.ts +++ b/apps/server/sources/app/presence/sessionCache.ts @@ -9,6 +9,7 @@ interface SessionCacheEntry { pendingUpdate: number | null; userId: string; sessionId: string; + active: boolean; } interface MachineCacheEntry { @@ -85,13 +86,23 @@ class ActivityCache { const access = await checkSessionAccess(userId, sessionId); if (access) { + const session = await db.session.findUnique({ + where: { id: sessionId }, + select: { lastActiveAt: true, active: true }, + }); + if (!session?.lastActiveAt) { + // Fail closed: presence should not mark unknown sessions as valid. + return false; + } + // Cache the result this.sessionCache.set(cacheKey, { validUntil: now + this.CACHE_TTL, - lastUpdateSent: now, + lastUpdateSent: session.lastActiveAt.getTime(), pendingUpdate: null, userId, - sessionId + sessionId, + active: session.active, }); return true; } @@ -159,6 +170,14 @@ class ActivityCache { if (!cached) { return false; // Should validate first } + + // If the session is currently marked inactive, force a DB write to flip it back to active + // even if `lastActiveAt` is already recent (e.g. after a restart or previously-buggy writes). + if (!cached.active) { + cached.pendingUpdate = timestamp; + cached.active = true; + return true; + } // Only queue if time difference is significant const timeDiff = Math.abs(timestamp - cached.lastUpdateSent); @@ -203,6 +222,7 @@ class ActivityCache { if (!cached) return; cached.lastUpdateSent = timestamp; cached.pendingUpdate = null; + cached.active = true; } markMachineUpdateSent(machineId: string, timestamp: number): void { @@ -244,7 +264,7 @@ class ActivityCache { if (sessionUpdatesById.size > 0) { try { await Promise.all(Array.from(sessionUpdatesById.entries()).map(([sessionId, timestamp]) => - db.session.update({ + db.session.updateMany({ where: { id: sessionId }, data: { lastActiveAt: new Date(timestamp), active: true } }) diff --git a/apps/server/sources/app/presence/timeout.spec.ts b/apps/server/sources/app/presence/timeout.spec.ts new file mode 100644 index 000000000..620cff974 --- /dev/null +++ b/apps/server/sources/app/presence/timeout.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { resolvePresenceTimeoutConfig } from "./timeout"; + +describe("presence timeout config", () => { + it("uses default timeouts when env unset", () => { + const config = resolvePresenceTimeoutConfig({}); + expect(config).toEqual({ + sessionTimeoutMs: 10 * 60 * 1000, + machineTimeoutMs: 10 * 60 * 1000, + tickMs: 60 * 1000, + }); + }); + + it("accepts env overrides", () => { + const config = resolvePresenceTimeoutConfig({ + HAPPIER_PRESENCE_SESSION_TIMEOUT_MS: "35000", + HAPPIER_PRESENCE_MACHINE_TIMEOUT_MS: "45000", + HAPPIER_PRESENCE_TIMEOUT_TICK_MS: "1000", + }); + expect(config).toEqual({ sessionTimeoutMs: 35_000, machineTimeoutMs: 45_000, tickMs: 1_000 }); + }); + + it("falls back when env is invalid", () => { + const config = resolvePresenceTimeoutConfig({ + HAPPIER_PRESENCE_SESSION_TIMEOUT_MS: "nope", + HAPPIER_PRESENCE_MACHINE_TIMEOUT_MS: "0", + HAPPIER_PRESENCE_TIMEOUT_TICK_MS: "-1", + }); + expect(config).toEqual({ + sessionTimeoutMs: 10 * 60 * 1000, + machineTimeoutMs: 10 * 60 * 1000, + tickMs: 60 * 1000, + }); + }); +}); + diff --git a/apps/server/sources/app/presence/timeout.ts b/apps/server/sources/app/presence/timeout.ts index 20cbbc3e0..26775c8b4 100644 --- a/apps/server/sources/app/presence/timeout.ts +++ b/apps/server/sources/app/presence/timeout.ts @@ -1,10 +1,30 @@ import { db } from "@/storage/db"; +import { parseIntEnv } from "@/config/env"; import { delay } from "@/utils/runtime/delay"; import { forever } from "@/utils/runtime/forever"; import { shutdownSignal } from "@/utils/process/shutdown"; import { buildMachineActivityEphemeral, buildSessionActivityEphemeral, eventRouter } from "@/app/events/eventRouter"; +export interface PresenceTimeoutConfig { + sessionTimeoutMs: number; + machineTimeoutMs: number; + tickMs: number; +} + +const DEFAULT_PRESENCE_SESSION_TIMEOUT_MS = 10 * 60 * 1000; +const DEFAULT_PRESENCE_MACHINE_TIMEOUT_MS = 10 * 60 * 1000; +const DEFAULT_PRESENCE_TIMEOUT_TICK_MS = 60 * 1000; + +export function resolvePresenceTimeoutConfig(env: NodeJS.ProcessEnv = process.env): PresenceTimeoutConfig { + return { + sessionTimeoutMs: parseIntEnv(env.HAPPIER_PRESENCE_SESSION_TIMEOUT_MS, DEFAULT_PRESENCE_SESSION_TIMEOUT_MS, { min: 1 }), + machineTimeoutMs: parseIntEnv(env.HAPPIER_PRESENCE_MACHINE_TIMEOUT_MS, DEFAULT_PRESENCE_MACHINE_TIMEOUT_MS, { min: 1 }), + tickMs: parseIntEnv(env.HAPPIER_PRESENCE_TIMEOUT_TICK_MS, DEFAULT_PRESENCE_TIMEOUT_TICK_MS, { min: 1 }), + }; +} + export function startTimeout() { + const timeoutConfig = resolvePresenceTimeoutConfig(process.env); forever('session-timeout', async () => { while (true) { // Find timed out sessions @@ -12,7 +32,7 @@ export function startTimeout() { where: { active: true, lastActiveAt: { - lte: new Date(Date.now() - 1000 * 60 * 10) // 10 minutes + lte: new Date(Date.now() - timeoutConfig.sessionTimeoutMs) } } }); @@ -36,7 +56,7 @@ export function startTimeout() { where: { active: true, lastActiveAt: { - lte: new Date(Date.now() - 1000 * 60 * 10) // 10 minutes + lte: new Date(Date.now() - timeoutConfig.machineTimeoutMs) } } }); @@ -55,8 +75,7 @@ export function startTimeout() { }); } - // Wait for 1 minute - await delay(1000 * 60, shutdownSignal); + await delay(timeoutConfig.tickMs, shutdownSignal); } }); } diff --git a/apps/server/sources/app/session/encryptionRejectionCodes.ts b/apps/server/sources/app/session/encryptionRejectionCodes.ts new file mode 100644 index 000000000..3cd1ff49a --- /dev/null +++ b/apps/server/sources/app/session/encryptionRejectionCodes.ts @@ -0,0 +1,22 @@ +export type EncryptionPolicyRejectionCode = + | "session_encryption_mode_mismatch" + | "storage_policy_requires_e2ee" + | "storage_policy_requires_plaintext"; + +export function resolveEncryptionWriteRejectionCode(params: Readonly<{ + storagePolicy: string; + sessionEncryptionMode: "e2ee" | "plain"; + writeKind: "encrypted" | "plain"; +}>): EncryptionPolicyRejectionCode { + if (params.storagePolicy === "required_e2ee") return "storage_policy_requires_e2ee"; + if (params.storagePolicy === "plaintext_only") return "storage_policy_requires_plaintext"; + return "session_encryption_mode_mismatch"; +} + +export function resolveRequestedSessionModeRejectionCode(params: Readonly<{ + storagePolicy: string; +}>): EncryptionPolicyRejectionCode { + if (params.storagePolicy === "plaintext_only") return "storage_policy_requires_plaintext"; + return "storage_policy_requires_e2ee"; +} + diff --git a/apps/server/sources/app/session/messageContent/normalizeIncomingSessionMessageContent.spec.ts b/apps/server/sources/app/session/messageContent/normalizeIncomingSessionMessageContent.spec.ts new file mode 100644 index 000000000..71faadfb9 --- /dev/null +++ b/apps/server/sources/app/session/messageContent/normalizeIncomingSessionMessageContent.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeIncomingSessionMessageContent } from "./normalizeIncomingSessionMessageContent"; + +describe("normalizeIncomingSessionMessageContent", () => { + it("wraps legacy ciphertext strings as encrypted content", () => { + expect(normalizeIncomingSessionMessageContent("aGVsbG8=")).toEqual({ t: "encrypted", c: "aGVsbG8=" }); + }); + + it("accepts encrypted envelope objects", () => { + expect(normalizeIncomingSessionMessageContent({ t: "encrypted", c: "abc" })).toEqual({ t: "encrypted", c: "abc" }); + }); + + it("accepts plain envelope objects", () => { + expect(normalizeIncomingSessionMessageContent({ t: "plain", v: { type: "user", text: "hi" } })).toEqual({ + t: "plain", + v: { type: "user", text: "hi" }, + }); + }); + + it("returns null for invalid inputs", () => { + expect(normalizeIncomingSessionMessageContent(null)).toBeNull(); + expect(normalizeIncomingSessionMessageContent({})).toBeNull(); + expect(normalizeIncomingSessionMessageContent({ t: "plain" })).toBeNull(); + expect(normalizeIncomingSessionMessageContent({ t: "encrypted", c: 123 })).toBeNull(); + }); +}); + diff --git a/apps/server/sources/app/session/messageContent/normalizeIncomingSessionMessageContent.ts b/apps/server/sources/app/session/messageContent/normalizeIncomingSessionMessageContent.ts new file mode 100644 index 000000000..324ae4da2 --- /dev/null +++ b/apps/server/sources/app/session/messageContent/normalizeIncomingSessionMessageContent.ts @@ -0,0 +1,24 @@ +export function normalizeIncomingSessionMessageContent(raw: unknown): PrismaJson.SessionMessageContent | null { + if (typeof raw === "string") { + const ciphertext = raw.trim(); + if (!ciphertext) return null; + return { t: "encrypted", c: ciphertext }; + } + + if (!raw || typeof raw !== "object") return null; + + const candidate = raw as Record<string, unknown>; + const t = candidate.t; + if (t === "encrypted") { + const c = candidate.c; + if (typeof c !== "string" || !c.trim()) return null; + return { t: "encrypted", c }; + } + if (t === "plain") { + if (!("v" in candidate)) return null; + return { t: "plain", v: candidate.v }; + } + + return null; +} + diff --git a/apps/server/sources/app/session/pending/materializeNextPendingMessage.ts b/apps/server/sources/app/session/pending/materializeNextPendingMessage.ts index 35aaa5111..f30c8ca4d 100644 --- a/apps/server/sources/app/session/pending/materializeNextPendingMessage.ts +++ b/apps/server/sources/app/session/pending/materializeNextPendingMessage.ts @@ -2,6 +2,8 @@ import { markSessionParticipantsChanged, type SessionParticipantCursor } from "@ import { markPendingStateChangedParticipants } from "@/app/session/pending/markPendingStateChangedParticipants"; import { resolveSessionPendingOwnerAccess } from "@/app/session/pending/resolveSessionPendingAccess"; import { inTx, type Tx } from "@/storage/inTx"; +import { readEncryptionFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { isStoredContentKindAllowedForSessionByStoragePolicy, type SessionStoredContentKind } from "@happier-dev/protocol"; type ParticipantCursor = SessionParticipantCursor; @@ -97,6 +99,14 @@ export async function materializeNextPendingMessage(params: { try { return await inTx(async (tx) => { + const sessionModeRow = await tx.session.findUnique({ + where: { id: sessionId }, + select: { encryptionMode: true }, + }); + if (!sessionModeRow) return { ok: false, error: "session-not-found" } as const; + const sessionEncryptionMode: "e2ee" | "plain" = sessionModeRow.encryptionMode === "plain" ? "plain" : "e2ee"; + const policy = readEncryptionFeatureEnv(process.env); + const nextPending = await tx.sessionPendingMessage.findFirst({ where: { sessionId, status: "queued" }, orderBy: [{ position: "asc" }, { createdAt: "asc" }], @@ -109,6 +119,12 @@ export async function materializeNextPendingMessage(params: { const localId = nextPending.localId; const content = toSessionMessageContentFromPending(nextPending.content as PrismaJson.SessionPendingMessageContent); + + const writeKind: SessionStoredContentKind = content.t === "plain" ? "plain" : "encrypted"; + if (!isStoredContentKindAllowedForSessionByStoragePolicy(policy.storagePolicy, sessionEncryptionMode, writeKind)) { + return { ok: false, error: "invalid-params" } as const; + } + const created = await createSessionMessageFromPending(tx, { sessionId, localId, content }); await tx.sessionPendingMessage.delete({ diff --git a/apps/server/sources/app/session/pending/pendingMessageService.spec.ts b/apps/server/sources/app/session/pending/pendingMessageService.spec.ts new file mode 100644 index 000000000..a5cd7b881 --- /dev/null +++ b/apps/server/sources/app/session/pending/pendingMessageService.spec.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let currentTx: any; + +vi.mock("@/storage/inTx", () => ({ + inTx: async (fn: any) => await fn(currentTx), +})); + +const resolveSessionPendingEditAccess = vi.fn(async (..._args: any[]) => ({ ok: true, isOwner: true })); +vi.mock("@/app/session/pending/resolveSessionPendingAccess", () => ({ + resolveSessionPendingEditAccess: (...args: any[]) => resolveSessionPendingEditAccess(...args), + resolveSessionPendingViewAccess: vi.fn(async () => ({ ok: true, isOwner: true })), +})); + +const applyPendingSessionStateChange = vi.fn(async (..._args: any[]) => ({ + pendingCount: 1, + pendingVersion: 1, + participantCursors: [], +})); +vi.mock("@/app/session/pending/applyPendingSessionStateChange", () => ({ + applyPendingSessionStateChange: (...args: any[]) => applyPendingSessionStateChange(...args), +})); + +import { enqueuePendingMessage, updatePendingMessage } from "./pendingMessageService"; + +const enqueuePendingMessageCompat = enqueuePendingMessage as unknown as (params: any) => Promise<any>; +const updatePendingMessageCompat = updatePendingMessage as unknown as (params: any) => Promise<any>; + +describe("pendingMessageService", () => { + beforeEach(() => { + resolveSessionPendingEditAccess.mockReset(); + resolveSessionPendingEditAccess.mockResolvedValue({ ok: true, isOwner: true }); + applyPendingSessionStateChange.mockReset(); + applyPendingSessionStateChange.mockResolvedValue({ pendingCount: 1, pendingVersion: 1, participantCursors: [] }); + + currentTx = { + session: { + findUnique: vi.fn(), + }, + sessionPendingMessage: { + findUnique: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + }, + }; + }); + + it("stores plain content when session encryptionMode is plain and storagePolicy is optional", async () => { + const createdAt = new Date("2020-01-01T00:00:00.000Z"); + const prevStoragePolicy = process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + + try { + currentTx.session.findUnique.mockResolvedValue({ encryptionMode: "plain", pendingCount: 0, pendingVersion: 0 }); + currentTx.sessionPendingMessage.findUnique.mockResolvedValue(null); + currentTx.sessionPendingMessage.findFirst.mockResolvedValue(null); + currentTx.sessionPendingMessage.create.mockResolvedValue({ + localId: "l1", + content: { t: "plain", v: { type: "user", text: "hi" } }, + status: "queued", + position: 1, + createdAt, + updatedAt: createdAt, + discardedAt: null, + discardedReason: null, + authorAccountId: "u1", + }); + + const res = await enqueuePendingMessageCompat({ + actorUserId: "u1", + sessionId: "s1", + localId: "l1", + content: { t: "plain", v: { type: "user", text: "hi" } }, + }); + + expect(res.ok).toBe(true); + expect(currentTx.sessionPendingMessage.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + content: { t: "plain", v: { type: "user", text: "hi" } }, + }), + }), + ); + } finally { + if (typeof prevStoragePolicy === "string") process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = prevStoragePolicy; + else delete (process.env as any).HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + } + }); + + it("rejects encrypted writes when session encryptionMode is plain", async () => { + const createdAt = new Date("2020-01-01T00:00:00.000Z"); + const prevStoragePolicy = process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + + try { + currentTx.session.findUnique.mockResolvedValue({ encryptionMode: "plain", pendingCount: 0, pendingVersion: 0 }); + currentTx.sessionPendingMessage.findUnique.mockResolvedValue(null); + currentTx.sessionPendingMessage.findFirst.mockResolvedValue(null); + currentTx.sessionPendingMessage.create.mockResolvedValue({ + localId: "l1", + content: { t: "encrypted", c: "cipher" }, + status: "queued", + position: 1, + createdAt, + updatedAt: createdAt, + discardedAt: null, + discardedReason: null, + authorAccountId: "u1", + }); + + const res = await enqueuePendingMessageCompat({ + actorUserId: "u1", + sessionId: "s1", + localId: "l1", + ciphertext: "cipher", + }); + + expect(res).toEqual({ ok: false, error: "invalid-params", code: "session_encryption_mode_mismatch" }); + expect(currentTx.sessionPendingMessage.create).not.toHaveBeenCalled(); + } finally { + if (typeof prevStoragePolicy === "string") process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = prevStoragePolicy; + else delete (process.env as any).HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + } + }); + + it("rejects encrypted update writes when session encryptionMode is plain (with a stable code)", async () => { + const prevStoragePolicy = process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + + try { + currentTx.session.findUnique.mockResolvedValue({ encryptionMode: "plain", pendingCount: 1, pendingVersion: 1 }); + currentTx.sessionPendingMessage.findUnique.mockResolvedValue({ id: "p1", status: "queued" }); + currentTx.sessionPendingMessage.update = vi.fn(); + + const res = await updatePendingMessageCompat({ + actorUserId: "u1", + sessionId: "s1", + localId: "l1", + ciphertext: "cipher", + }); + + expect(res).toEqual({ ok: false, error: "invalid-params", code: "session_encryption_mode_mismatch" }); + expect(currentTx.sessionPendingMessage.update).not.toHaveBeenCalled(); + } finally { + if (typeof prevStoragePolicy === "string") process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = prevStoragePolicy; + else delete (process.env as any).HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + } + }); + + it("updates pending content using plain envelopes when session encryptionMode is plain and storagePolicy is optional", async () => { + const prevStoragePolicy = process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + + try { + currentTx.session.findUnique.mockResolvedValue({ encryptionMode: "plain", pendingCount: 1, pendingVersion: 1 }); + currentTx.sessionPendingMessage.findUnique.mockResolvedValue({ id: "p1", status: "queued" }); + currentTx.sessionPendingMessage.update = vi.fn(); + + const res = await updatePendingMessageCompat({ + actorUserId: "u1", + sessionId: "s1", + localId: "l1", + content: { t: "plain", v: { type: "user", text: "hi" } }, + }); + + expect(res.ok).toBe(true); + expect(currentTx.sessionPendingMessage.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { content: { t: "plain", v: { type: "user", text: "hi" } } }, + }), + ); + } finally { + if (typeof prevStoragePolicy === "string") process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = prevStoragePolicy; + else delete (process.env as any).HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + } + }); +}); diff --git a/apps/server/sources/app/session/pending/pendingMessageService.ts b/apps/server/sources/app/session/pending/pendingMessageService.ts index d9de4afd6..b69ca7e25 100644 --- a/apps/server/sources/app/session/pending/pendingMessageService.ts +++ b/apps/server/sources/app/session/pending/pendingMessageService.ts @@ -8,6 +8,9 @@ import { import type { PendingMessageRow } from "@/app/session/pending/mapPendingMessageRow"; import { db } from "@/storage/db"; import { inTx } from "@/storage/inTx"; +import { readEncryptionFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { isStoredContentKindAllowedForSessionByStoragePolicy, type SessionStoredContentKind } from "@happier-dev/protocol"; +import { resolveEncryptionWriteRejectionCode, type EncryptionPolicyRejectionCode } from "@/app/session/encryptionRejectionCodes"; type ParticipantCursor = SessionParticipantCursor; @@ -81,26 +84,53 @@ export type EnqueuePendingMessageResult = pendingVersion: number; participantCursors: ParticipantCursor[]; } - | { ok: false; error: "session-not-found" | "forbidden" | "invalid-params" | "internal" }; + | { ok: false; error: "session-not-found" | "forbidden" | "invalid-params" | "internal"; code?: EncryptionPolicyRejectionCode }; export async function enqueuePendingMessage(params: { actorUserId: string; sessionId: string; localId: string; - ciphertext: string; -}): Promise<EnqueuePendingMessageResult> { +} & ( + | Readonly<{ ciphertext: string; content?: never }> + | Readonly<{ content: PrismaJson.SessionPendingMessageContent; ciphertext?: never }> +)): Promise<EnqueuePendingMessageResult> { const actorUserId = typeof params.actorUserId === "string" ? params.actorUserId : ""; const sessionId = typeof params.sessionId === "string" ? params.sessionId : ""; const localId = typeof params.localId === "string" ? params.localId : ""; - const ciphertext = typeof params.ciphertext === "string" ? params.ciphertext : ""; + const ciphertext = "ciphertext" in params && typeof params.ciphertext === "string" ? params.ciphertext : ""; + const content = + "content" in params ? params.content : ciphertext ? ({ t: "encrypted", c: ciphertext } satisfies PrismaJson.SessionPendingMessageContent) : null; - if (!actorUserId || !sessionId || !localId || !ciphertext) return { ok: false, error: "invalid-params" }; + if (!actorUserId || !sessionId || !localId || !content) return { ok: false, error: "invalid-params" }; + if (content.t === "encrypted" && (!content.c || typeof content.c !== "string")) return { ok: false, error: "invalid-params" }; + if (content.t === "plain" && !("v" in content)) return { ok: false, error: "invalid-params" }; const access = await resolveSessionPendingEditAccess(actorUserId, sessionId); if (!access.ok) return { ok: false, error: access.error }; try { return await inTx(async (tx) => { + const session = await tx.session.findUnique({ + where: { id: sessionId }, + select: { encryptionMode: true, pendingCount: true, pendingVersion: true }, + }); + if (!session) return { ok: false, error: "session-not-found" } as const; + + const sessionEncryptionMode: "e2ee" | "plain" = session.encryptionMode === "plain" ? "plain" : "e2ee"; + const writeKind: SessionStoredContentKind = content.t === "plain" ? "plain" : "encrypted"; + const policy = readEncryptionFeatureEnv(process.env); + if (!isStoredContentKindAllowedForSessionByStoragePolicy(policy.storagePolicy, sessionEncryptionMode, writeKind)) { + return { + ok: false, + error: "invalid-params", + code: resolveEncryptionWriteRejectionCode({ + storagePolicy: policy.storagePolicy, + sessionEncryptionMode, + writeKind, + }), + } as const; + } + const existing = await tx.sessionPendingMessage.findUnique({ where: { sessionId_localId: { sessionId, localId } }, select: { @@ -116,16 +146,12 @@ export async function enqueuePendingMessage(params: { }, }); if (existing) { - const session = await tx.session.findUnique({ - where: { id: sessionId }, - select: { pendingCount: true, pendingVersion: true }, - }); return { ok: true, didWrite: false, pending: mapPendingMessageRow(existing), - pendingCount: session?.pendingCount ?? 0, - pendingVersion: session?.pendingVersion ?? 0, + pendingCount: session.pendingCount ?? 0, + pendingVersion: session.pendingVersion ?? 0, participantCursors: [], }; } @@ -136,7 +162,6 @@ export async function enqueuePendingMessage(params: { select: { position: true }, }); const position = (lastQueued?.position ?? 0) + 1; - const content: PrismaJson.SessionPendingMessageContent = { t: "encrypted", c: ciphertext }; const created = await tx.sessionPendingMessage.create({ data: { @@ -182,33 +207,59 @@ export async function enqueuePendingMessage(params: { export type UpdatePendingMessageResult = | { ok: true; pendingVersion: number; pendingCount: number; participantCursors: ParticipantCursor[] } - | { ok: false; error: "session-not-found" | "forbidden" | "invalid-params" | "not-found" | "internal" }; + | { ok: false; error: "session-not-found" | "forbidden" | "invalid-params" | "not-found" | "internal"; code?: EncryptionPolicyRejectionCode }; export async function updatePendingMessage(params: { actorUserId: string; sessionId: string; localId: string; - ciphertext: string; -}): Promise<UpdatePendingMessageResult> { +} & ( + | Readonly<{ ciphertext: string; content?: never }> + | Readonly<{ content: PrismaJson.SessionPendingMessageContent; ciphertext?: never }> +)): Promise<UpdatePendingMessageResult> { const actorUserId = typeof params.actorUserId === "string" ? params.actorUserId : ""; const sessionId = typeof params.sessionId === "string" ? params.sessionId : ""; const localId = typeof params.localId === "string" ? params.localId : ""; - const ciphertext = typeof params.ciphertext === "string" ? params.ciphertext : ""; + const ciphertext = "ciphertext" in params && typeof params.ciphertext === "string" ? params.ciphertext : ""; + const content = + "content" in params ? params.content : ciphertext ? ({ t: "encrypted", c: ciphertext } satisfies PrismaJson.SessionPendingMessageContent) : null; - if (!actorUserId || !sessionId || !localId || !ciphertext) return { ok: false, error: "invalid-params" }; + if (!actorUserId || !sessionId || !localId || !content) return { ok: false, error: "invalid-params" }; + if (content.t === "encrypted" && (!content.c || typeof content.c !== "string")) return { ok: false, error: "invalid-params" }; + if (content.t === "plain" && !("v" in content)) return { ok: false, error: "invalid-params" }; const access = await resolveSessionPendingEditAccess(actorUserId, sessionId); if (!access.ok) return { ok: false, error: access.error }; try { return await inTx(async (tx) => { + const session = await tx.session.findUnique({ + where: { id: sessionId }, + select: { encryptionMode: true }, + }); + if (!session) return { ok: false, error: "session-not-found" } as const; + + const sessionEncryptionMode: "e2ee" | "plain" = session.encryptionMode === "plain" ? "plain" : "e2ee"; + const writeKind: SessionStoredContentKind = content.t === "plain" ? "plain" : "encrypted"; + const policy = readEncryptionFeatureEnv(process.env); + if (!isStoredContentKindAllowedForSessionByStoragePolicy(policy.storagePolicy, sessionEncryptionMode, writeKind)) { + return { + ok: false, + error: "invalid-params", + code: resolveEncryptionWriteRejectionCode({ + storagePolicy: policy.storagePolicy, + sessionEncryptionMode, + writeKind, + }), + } as const; + } + const existing = await tx.sessionPendingMessage.findUnique({ where: { sessionId_localId: { sessionId, localId } }, select: { id: true, status: true }, }); if (!existing) return { ok: false, error: "not-found" } as const; - const content: PrismaJson.SessionPendingMessageContent = { t: "encrypted", c: ciphertext }; await tx.sessionPendingMessage.update({ where: { sessionId_localId: { sessionId, localId } }, data: { content }, diff --git a/apps/server/sources/app/session/sessionWriteService.spec.ts b/apps/server/sources/app/session/sessionWriteService.spec.ts index c6156aa95..f3dcff055 100644 --- a/apps/server/sources/app/session/sessionWriteService.spec.ts +++ b/apps/server/sources/app/session/sessionWriteService.spec.ts @@ -16,7 +16,7 @@ vi.mock("@/app/changes/markAccountChanged", () => ({ markAccountChanged: (...args: any[]) => markAccountChanged(...args), })); -const dbFindFirst = vi.fn(); +const dbFindUnique = vi.fn(); const dbSessionFindUnique = vi.fn(); const dbSessionShareFindUnique = vi.fn(); vi.mock("@/storage/db", () => ({ @@ -28,18 +28,20 @@ vi.mock("@/storage/db", () => ({ findUnique: (...args: any[]) => dbSessionShareFindUnique(...args), }, sessionMessage: { - findFirst: (...args: any[]) => dbFindFirst(...args), + findUnique: (...args: any[]) => dbFindUnique(...args), }, }, })); import { createSessionMessage, patchSession, updateSessionAgentState, updateSessionMetadata } from "./sessionWriteService"; +const createSessionMessageCompat = createSessionMessage as unknown as (params: any) => Promise<any>; + describe("sessionWriteService", () => { beforeEach(() => { getSessionParticipantUserIds.mockReset(); markAccountChanged.mockReset(); - dbFindFirst.mockReset(); + dbFindUnique.mockReset(); dbSessionFindUnique.mockReset(); dbSessionShareFindUnique.mockReset(); @@ -53,7 +55,7 @@ describe("sessionWriteService", () => { findUnique: vi.fn(), }, sessionMessage: { - findFirst: vi.fn(), + findUnique: vi.fn(), create: vi.fn(), }, }; @@ -61,7 +63,7 @@ describe("sessionWriteService", () => { describe("createSessionMessage", () => { it("returns existing message for (sessionId, localId) without writing or marking changes", async () => { - currentTx.sessionMessage.findFirst.mockResolvedValue({ id: "m1", seq: 4, localId: "l1", createdAt: new Date(1) }); + currentTx.sessionMessage.findUnique.mockResolvedValue({ id: "m1", seq: 4, localId: "l1", createdAt: new Date(1) }); currentTx.session.findUnique.mockResolvedValue({ accountId: "u1" }); currentTx.sessionShare.findUnique.mockResolvedValue(null); @@ -84,7 +86,7 @@ describe("sessionWriteService", () => { }); it("rejects message creation if actor has no edit access", async () => { - currentTx.sessionMessage.findFirst.mockResolvedValue(null); + currentTx.sessionMessage.findUnique.mockResolvedValue(null); currentTx.session.findUnique.mockResolvedValue({ accountId: "owner" }); currentTx.sessionShare.findUnique.mockResolvedValue(null); @@ -103,7 +105,7 @@ describe("sessionWriteService", () => { const createdAt = new Date("2020-01-01T00:00:00.000Z"); const updatedAt = new Date("2020-01-01T00:00:00.000Z"); - currentTx.sessionMessage.findFirst.mockResolvedValue(null); + currentTx.sessionMessage.findUnique.mockResolvedValue(null); currentTx.session.findUnique.mockResolvedValue({ accountId: "u1" }); currentTx.session.update.mockResolvedValue({ seq: 10 }); currentTx.sessionMessage.create.mockResolvedValue({ @@ -150,14 +152,14 @@ describe("sessionWriteService", () => { }); it("handles localId races by returning the winner row on P2002", async () => { - currentTx.sessionMessage.findFirst.mockResolvedValue(null); + currentTx.sessionMessage.findUnique.mockResolvedValue(null); currentTx.session.findUnique.mockResolvedValue({ accountId: "u1" }); currentTx.session.update.mockResolvedValue({ seq: 10 }); currentTx.sessionMessage.create.mockRejectedValue({ code: "P2002" }); dbSessionFindUnique.mockResolvedValue({ accountId: "u1" }); dbSessionShareFindUnique.mockResolvedValue(null); - dbFindFirst.mockResolvedValue({ id: "mExisting", seq: 9, localId: "l1", createdAt: new Date(1) }); + dbFindUnique.mockResolvedValue({ id: "mExisting", seq: 9, localId: "l1", createdAt: new Date(1) }); const res = await createSessionMessage({ actorUserId: "u1", @@ -173,6 +175,80 @@ describe("sessionWriteService", () => { participantCursors: [], }); }); + + it("rejects encrypted writes when the session encryptionMode is plain (with a stable code)", async () => { + const createdAt = new Date("2020-01-01T00:00:00.000Z"); + const prevStoragePolicy = process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + currentTx.sessionMessage.findUnique.mockResolvedValue(null); + currentTx.session.findUnique.mockResolvedValue({ accountId: "u1", encryptionMode: "plain" }); + currentTx.sessionShare.findUnique.mockResolvedValue(null); + currentTx.session.update.mockResolvedValue({ seq: 1 }); + currentTx.sessionMessage.create.mockResolvedValue({ + id: "m1", + seq: 1, + localId: null, + content: { t: "encrypted", c: "cipher" }, + createdAt, + updatedAt: createdAt, + }); + getSessionParticipantUserIds.mockResolvedValue(["u1"]); + markAccountChanged.mockResolvedValueOnce(101); + + const res = await createSessionMessage({ + actorUserId: "u1", + sessionId: "s1", + ciphertext: "cipher", + }); + + expect(res).toEqual({ ok: false, error: "invalid-params", code: "session_encryption_mode_mismatch" }); + expect(currentTx.session.update).not.toHaveBeenCalled(); + expect(currentTx.sessionMessage.create).not.toHaveBeenCalled(); + expect(markAccountChanged).not.toHaveBeenCalled(); + if (typeof prevStoragePolicy === "string") process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = prevStoragePolicy; + else delete (process.env as any).HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + }); + + it("stores plain content when the session encryptionMode is plain and storagePolicy is optional", async () => { + const createdAt = new Date("2020-01-01T00:00:00.000Z"); + const prevStoragePolicy = process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = "optional"; + + try { + currentTx.sessionMessage.findUnique.mockResolvedValue(null); + currentTx.session.findUnique.mockResolvedValue({ accountId: "u1", encryptionMode: "plain" }); + currentTx.sessionShare.findUnique.mockResolvedValue(null); + currentTx.session.update.mockResolvedValue({ seq: 1 }); + currentTx.sessionMessage.create.mockResolvedValue({ + id: "m1", + seq: 1, + localId: null, + content: { t: "plain", v: { type: "user", text: "hi" } }, + createdAt, + updatedAt: createdAt, + }); + getSessionParticipantUserIds.mockResolvedValue(["u1"]); + markAccountChanged.mockResolvedValueOnce(101); + + const res = await createSessionMessageCompat({ + actorUserId: "u1", + sessionId: "s1", + content: { t: "plain", v: { type: "user", text: "hi" } }, + }); + + expect(res.ok).toBe(true); + expect(currentTx.sessionMessage.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + content: { t: "plain", v: { type: "user", text: "hi" } }, + }), + }), + ); + } finally { + if (typeof prevStoragePolicy === "string") process.env.HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY = prevStoragePolicy; + else delete (process.env as any).HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY; + } + }); }); describe("updateSessionMetadata", () => { diff --git a/apps/server/sources/app/session/sessionWriteService.ts b/apps/server/sources/app/session/sessionWriteService.ts index 85149ac2e..3c60d1c4b 100644 --- a/apps/server/sources/app/session/sessionWriteService.ts +++ b/apps/server/sources/app/session/sessionWriteService.ts @@ -3,24 +3,29 @@ import { db } from "@/storage/db"; import { inTx, type Tx } from "@/storage/inTx"; import { isPrismaErrorCode } from "@/storage/prisma"; import { log } from "@/utils/logging/log"; +import { readEncryptionFeatureEnv } from "@/app/features/catalog/readFeatureEnv"; +import { isStoredContentKindAllowedForSessionByStoragePolicy, type SessionStoredContentKind } from "@happier-dev/protocol"; +import { resolveEncryptionWriteRejectionCode, type EncryptionPolicyRejectionCode } from "@/app/session/encryptionRejectionCodes"; type ParticipantCursor = SessionParticipantCursor; type EnsureSessionEditAccessResult = - | { ok: true; sessionOwnerId: string } + | { ok: true; sessionOwnerId: string; sessionEncryptionMode: "e2ee" | "plain" } | { ok: false; error: "session-not-found" | "forbidden" }; async function ensureSessionEditAccess(tx: Tx, params: { actorUserId: string; sessionId: string }): Promise<EnsureSessionEditAccessResult> { const session = await tx.session.findUnique({ where: { id: params.sessionId }, - select: { accountId: true }, + select: { accountId: true, encryptionMode: true }, }); if (!session) { return { ok: false, error: "session-not-found" }; } + const sessionEncryptionMode: "e2ee" | "plain" = session.encryptionMode === "plain" ? "plain" : "e2ee"; + if (session.accountId === params.actorUserId) { - return { ok: true, sessionOwnerId: session.accountId }; + return { ok: true, sessionOwnerId: session.accountId, sessionEncryptionMode }; } const share = await tx.sessionShare.findUnique({ @@ -37,7 +42,7 @@ async function ensureSessionEditAccess(tx: Tx, params: { actorUserId: string; se return { ok: false, error: "forbidden" }; } - return { ok: true, sessionOwnerId: session.accountId }; + return { ok: true, sessionOwnerId: session.accountId, sessionEncryptionMode }; } async function ensureSessionEditAccessNoTx(params: { actorUserId: string; sessionId: string }): Promise<EnsureSessionEditAccessResult> { @@ -57,20 +62,36 @@ export type CreateSessionMessageResult = message: { id: string; seq: number; localId: string | null; createdAt: Date }; participantCursors: []; } - | { ok: false; error: "invalid-params" | "forbidden" | "session-not-found" | "internal" }; + | { ok: false; error: "invalid-params" | "forbidden" | "session-not-found" | "internal"; code?: EncryptionPolicyRejectionCode }; -export async function createSessionMessage(params: { +type CreateSessionMessageParamsBase = Readonly<{ actorUserId: string; sessionId: string; - ciphertext: string; localId?: string | null; -}): Promise<CreateSessionMessageResult> { +}>; + +export async function createSessionMessage( + params: CreateSessionMessageParamsBase & + ( + | Readonly<{ ciphertext: string; content?: never }> + | Readonly<{ content: PrismaJson.SessionMessageContent; ciphertext?: never }> + ), +): Promise<CreateSessionMessageResult> { const sessionId = typeof params.sessionId === "string" ? params.sessionId : ""; const actorUserId = typeof params.actorUserId === "string" ? params.actorUserId : ""; - const ciphertext = typeof params.ciphertext === "string" ? params.ciphertext : ""; + const ciphertext = "ciphertext" in params && typeof params.ciphertext === "string" ? params.ciphertext : ""; const localId = typeof params.localId === "string" ? params.localId : null; - if (!sessionId || !actorUserId || !ciphertext) { + const content = "content" in params ? params.content : ciphertext ? ({ t: "encrypted", c: ciphertext } satisfies PrismaJson.SessionMessageContent) : null; + + if (!sessionId || !actorUserId || !content) { + return { ok: false, error: "invalid-params" }; + } + + if (content.t === "encrypted" && (!content.c || typeof content.c !== "string")) { + return { ok: false, error: "invalid-params" }; + } + if (content.t === "plain" && !("v" in content)) { return { ok: false, error: "invalid-params" }; } @@ -81,9 +102,25 @@ export async function createSessionMessage(params: { return { ok: false, error: access.error }; } + const encryptionPolicy = readEncryptionFeatureEnv(process.env); + const writeKind: SessionStoredContentKind = content.t === "plain" ? "plain" : "encrypted"; + if ( + !isStoredContentKindAllowedForSessionByStoragePolicy(encryptionPolicy.storagePolicy, access.sessionEncryptionMode, writeKind) + ) { + return { + ok: false, + error: "invalid-params", + code: resolveEncryptionWriteRejectionCode({ + storagePolicy: encryptionPolicy.storagePolicy, + sessionEncryptionMode: access.sessionEncryptionMode, + writeKind, + }), + }; + } + if (localId) { - const existing = await tx.sessionMessage.findFirst({ - where: { sessionId, localId }, + const existing = await tx.sessionMessage.findUnique({ + where: { sessionId_localId: { sessionId, localId } }, select: { id: true, seq: true, localId: true, createdAt: true }, }); if (existing) { @@ -97,12 +134,11 @@ export async function createSessionMessage(params: { data: { seq: { increment: 1 } }, }); - const msgContent: PrismaJson.SessionMessageContent = { t: "encrypted", c: ciphertext }; const created = await tx.sessionMessage.create({ data: { sessionId, seq: next.seq, - content: msgContent, + content, localId, }, select: { id: true, seq: true, localId: true, content: true, createdAt: true, updatedAt: true }, @@ -138,8 +174,8 @@ export async function createSessionMessage(params: { if (!access.ok) { return { ok: false, error: access.error }; } - const existing = await db.sessionMessage.findFirst({ - where: { sessionId, localId }, + const existing = await db.sessionMessage.findUnique({ + where: { sessionId_localId: { sessionId, localId } }, select: { id: true, seq: true, localId: true, createdAt: true }, }); if (existing) { diff --git a/apps/server/sources/app/social/type.ts b/apps/server/sources/app/social/type.ts index b6a3e9a2b..4b48bc0ab 100644 --- a/apps/server/sources/app/social/type.ts +++ b/apps/server/sources/app/social/type.ts @@ -64,7 +64,7 @@ export function buildUserProfile( lastName: string | null; username: string | null; avatar: ImageRef | null; - publicKey: string; + publicKey: string | null; contentPublicKey: Uint8Array<ArrayBuffer> | null; contentPublicKeySig: Uint8Array<ArrayBuffer> | null; }, diff --git a/apps/server/sources/startServer.ts b/apps/server/sources/startServer.ts index 8e3410d70..8ba84f90a 100644 --- a/apps/server/sources/startServer.ts +++ b/apps/server/sources/startServer.ts @@ -96,7 +96,7 @@ export async function startServer(flavor: ServerFlavor): Promise<void> { if (filesBackend === 'local') { initFilesLocalFromEnv(process.env); } else if (filesBackend === 's3') { - initFilesS3FromEnv(process.env); + await initFilesS3FromEnv(process.env); } else { throw new Error(`Unsupported HAPPY_FILES_BACKEND/HAPPIER_FILES_BACKEND: ${String(filesBackend)}`); } diff --git a/apps/server/sources/storage/blob/files.spec.ts b/apps/server/sources/storage/blob/files.spec.ts index bac3fdc1d..e0c9dc241 100644 --- a/apps/server/sources/storage/blob/files.spec.ts +++ b/apps/server/sources/storage/blob/files.spec.ts @@ -1,6 +1,26 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it, vi } from 'vitest'; describe('storage/files (S3 env parsing)', () => { + it('does not require the MinIO dependency when the local backend is selected', async () => { + const dir = mkdtempSync(join(tmpdir(), 'happier-server-files-local-')); + try { + vi.resetModules(); + vi.doMock('minio', () => { + throw new Error('MinIO should not be imported for the local backend'); + }); + + const { initFilesLocalFromEnv, loadFiles } = await import('./files'); + initFilesLocalFromEnv({ HAPPIER_SERVER_LIGHT_FILES_DIR: dir } as unknown as NodeJS.ProcessEnv); + await expect(loadFiles()).resolves.toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('passes an explicit S3 region to the MinIO client (S3_REGION override, default us-east-1)', async () => { vi.resetModules(); @@ -15,7 +35,7 @@ describe('storage/files (S3 env parsing)', () => { const { initFilesS3FromEnv } = await import('./files'); - initFilesS3FromEnv({ + await initFilesS3FromEnv({ S3_HOST: 'example.com', S3_BUCKET: 'bucket', S3_PUBLIC_URL: 'https://cdn.example.com', @@ -33,7 +53,7 @@ describe('storage/files (S3 env parsing)', () => { }); const { initFilesS3FromEnv: init2 } = await import('./files'); - init2({ + await init2({ S3_HOST: 'example.com', S3_BUCKET: 'bucket', S3_PUBLIC_URL: 'https://cdn.example.com', @@ -48,7 +68,7 @@ describe('storage/files (S3 env parsing)', () => { vi.resetModules(); const { initFilesS3FromEnv } = await import('./files'); - expect(() => + await expect( initFilesS3FromEnv({ S3_HOST: 'example.com', S3_PORT: 'nope', @@ -57,7 +77,7 @@ describe('storage/files (S3 env parsing)', () => { S3_ACCESS_KEY: 'access', S3_SECRET_KEY: 'secret', } as unknown as NodeJS.ProcessEnv), - ).toThrow(/S3_PORT/i); + ).rejects.toThrow(/S3_PORT/i); }); it('throws when the configured bucket does not exist', async () => { @@ -75,7 +95,7 @@ describe('storage/files (S3 env parsing)', () => { const { initFilesS3FromEnv, loadFiles } = await import('./files'); - initFilesS3FromEnv({ + await initFilesS3FromEnv({ S3_HOST: 'example.com', S3_BUCKET: 'bucket', S3_PUBLIC_URL: 'https://cdn.example.com', diff --git a/apps/server/sources/storage/blob/files.ts b/apps/server/sources/storage/blob/files.ts index 0ababed68..c7cc63370 100644 --- a/apps/server/sources/storage/blob/files.ts +++ b/apps/server/sources/storage/blob/files.ts @@ -1,4 +1,3 @@ -import * as Minio from 'minio'; import { ensureLightFilesDir, getLightPublicUrl, readLightPublicFile, writeLightPublicFile } from '@/flavors/light/files'; export type ImageRef = { @@ -17,7 +16,7 @@ export type PublicFilesBackend = { let backend: PublicFilesBackend | null = null; -export function initFilesS3FromEnv(env: NodeJS.ProcessEnv = process.env): void { +export async function initFilesS3FromEnv(env: NodeJS.ProcessEnv = process.env): Promise<void> { const s3Host = requiredEnv(env, 'S3_HOST'); const s3PortRaw = env.S3_PORT?.trim(); let s3Port: number | undefined; @@ -34,6 +33,7 @@ export function initFilesS3FromEnv(env: NodeJS.ProcessEnv = process.env): void { const s3public = requiredEnv(env, 'S3_PUBLIC_URL'); const s3Region = env.S3_REGION?.trim() ? env.S3_REGION.trim() : 'us-east-1'; + const Minio = await import('minio'); const s3client = new Minio.Client({ endPoint: s3Host, port: s3Port, diff --git a/apps/server/sources/storage/prisma.generatedClients.spec.ts b/apps/server/sources/storage/prisma.generatedClients.spec.ts index e3b9f47a1..684928c6a 100644 --- a/apps/server/sources/storage/prisma.generatedClients.spec.ts +++ b/apps/server/sources/storage/prisma.generatedClients.spec.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { resolveGeneratedClientEntrypoint, resolvePackagedGeneratedClientEntrypoint } from "./prisma"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + resolveGeneratedClientEntrypoint, + resolvePackagedGeneratedClientEntrypoint, + resolvePreferredGeneratedClientEntrypoint, +} from "./prisma"; describe("resolveGeneratedClientEntrypoint", () => { it("appends /index.js for directory specifiers", () => { @@ -21,4 +29,15 @@ describe("resolveGeneratedClientEntrypoint", () => { "/opt/happier/generated/mysql-client/index.js", ); }); + + it("prefers packaged generated clients when present next to executable", async () => { + const root = await mkdtemp(join(tmpdir(), "happier-server-packaged-prisma-")); + const execPath = join(root, "happier-server"); + const packaged = join(root, "generated", "sqlite-client", "index.js"); + await mkdir(join(root, "generated", "sqlite-client"), { recursive: true }); + await writeFile(packaged, "export const PrismaClient = class PrismaClient {};\n", "utf-8"); + + const resolved = resolvePreferredGeneratedClientEntrypoint("sqlite", execPath); + expect(resolved).toBe(packaged); + }); }); diff --git a/apps/server/sources/storage/prisma.ts b/apps/server/sources/storage/prisma.ts index 6d4b3f577..8e89305f2 100644 --- a/apps/server/sources/storage/prisma.ts +++ b/apps/server/sources/storage/prisma.ts @@ -1,8 +1,9 @@ import { Prisma, PrismaClient } from "@prisma/client"; import { PGlite } from "@electric-sql/pglite"; import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; +import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { pathToFileURL } from "node:url"; import { acquirePgliteDirLock } from "./locks/pgliteLock"; @@ -37,6 +38,21 @@ export function resolvePackagedGeneratedClientEntrypoint( return join(dirname(executablePath), "generated", `${provider}-client`, "index.js"); } +export function resolvePreferredGeneratedClientEntrypoint( + provider: "mysql" | "sqlite", + executablePath: string = process.execPath, +): string { + const packaged = resolvePackagedGeneratedClientEntrypoint(provider, executablePath); + // Compiled binaries (e.g. Bun --compile) may embed workspace paths into generated Prisma clients + // (import.meta.url / __dirname). Prefer the on-disk packaged client next to the executable when present. + if (existsSync(packaged)) { + return packaged; + } + return provider === "mysql" + ? resolveGeneratedClientEntrypoint("../../generated/mysql-client") + : resolveGeneratedClientEntrypoint("../../generated/sqlite-client"); +} + let _db: PrismaClientType | null = null; let _pglite: PGlite | null = null; let _pgliteServer: PGLiteSocketServer | null = null; @@ -77,6 +93,14 @@ export function initDbPostgres(): void { } async function importGeneratedClient(provider: "mysql" | "sqlite"): Promise<any> { + const preferred = resolvePreferredGeneratedClientEntrypoint(provider); + if (isAbsolute(preferred)) { + try { + return await import(pathToFileURL(preferred).href); + } catch { + // Fall back to workspace path below. + } + } try { if (provider === "mysql") { return await import("../../generated/mysql-client/index.js"); diff --git a/apps/server/sources/storage/types.ts b/apps/server/sources/storage/types.ts index ab644ff2e..0f6f33564 100644 --- a/apps/server/sources/storage/types.ts +++ b/apps/server/sources/storage/types.ts @@ -2,10 +2,15 @@ import { ImageRef as ImageRefType } from "./blob/files"; declare global { namespace PrismaJson { // Session message content types - type SessionMessageContent = { - t: 'encrypted'; - c: string; // Base64 encoded encrypted content - }; + type SessionMessageContent = + | { + t: 'encrypted'; + c: string; // Base64 encoded encrypted content + } + | { + t: 'plain'; + v: unknown; + }; // Pending queue content types (same encrypted wrapper). type SessionPendingMessageContent = SessionMessageContent; diff --git a/apps/server/sources/utils/network/urlSafety.spec.ts b/apps/server/sources/utils/network/urlSafety.spec.ts index 02ac75eb6..f39ca971e 100644 --- a/apps/server/sources/utils/network/urlSafety.spec.ts +++ b/apps/server/sources/utils/network/urlSafety.spec.ts @@ -8,6 +8,13 @@ describe('isLoopbackHostname', () => { expect(isLoopbackHostname('localhost.')).toBe(true); }); + it('treats *.localhost as loopback', () => { + expect(isLoopbackHostname('happier.localhost')).toBe(true); + expect(isLoopbackHostname('happier.localhost.')).toBe(true); + expect(isLoopbackHostname('a.b.c.localhost')).toBe(true); + expect(isLoopbackHostname('HAPPIER.LOCALHOST')).toBe(true); + }); + it('treats 127.0.0.0/8 as loopback', () => { expect(isLoopbackHostname('127.0.0.1')).toBe(true); expect(isLoopbackHostname('127.0.0.2')).toBe(true); diff --git a/apps/server/sources/utils/network/urlSafety.ts b/apps/server/sources/utils/network/urlSafety.ts index 9b054e7ad..cefe9347d 100644 --- a/apps/server/sources/utils/network/urlSafety.ts +++ b/apps/server/sources/utils/network/urlSafety.ts @@ -102,6 +102,9 @@ export function isLoopbackHostname(hostname: string): boolean { const normalized = withoutZoneId.toLowerCase(); if (normalized === 'localhost') return true; + // RFC 6761: any name ending in ".localhost" is a special-use domain that resolves to loopback. + // This allows local dev setups to safely use `http://*.localhost` as an OAuth return URL. + if (normalized.endsWith('.localhost')) return true; const legacyIpv4Loopback = isLegacyIpv4LoopbackOrNull(normalized); if (legacyIpv4Loopback !== null) return legacyIpv4Loopback; diff --git a/apps/stack/docs/isolated-linux-vm.md b/apps/stack/docs/isolated-linux-vm.md index 34763bea3..dbf365f8a 100644 --- a/apps/stack/docs/isolated-linux-vm.md +++ b/apps/stack/docs/isolated-linux-vm.md @@ -12,12 +12,12 @@ This avoids Docker/container UX issues (browser opening, Expo networking, file w brew install lima ``` -### 1b) Automated E2E smoke test (optional) +### 1b) Automated smoke test (optional) -On your macOS host (this repo): +On your macOS host (this repo, from `apps/stack/`): ```bash -./scripts/provision/macos-lima-hstack-e2e.sh happy-e2e +./scripts/provision/macos-lima-hstack-smoke.sh happy-e2e ``` What it validates (best-effort): @@ -28,18 +28,18 @@ What it validates (best-effort): Notes: - This runs inside the VM (Linux) and uses `npx` by default. -- You can pin the version under test: `HSTACK_VERSION=0.6.14 ./scripts/provision/macos-lima-hstack-e2e.sh happy-e2e`. +- You can pin the version under test: `HSTACK_VERSION=0.6.14 ./scripts/provision/macos-lima-hstack-smoke.sh happy-e2e`. - If you’re testing a fork, you can point the runner at your fork’s raw scripts: - `HSTACK_RAW_BASE=https://raw.githubusercontent.com/<owner>/<repo>/<ref>/apps/stack ./scripts/provision/macos-lima-hstack-e2e.sh happy-e2e`. + `HSTACK_RAW_BASE=https://raw.githubusercontent.com/<owner>/<repo>/<ref>/apps/stack ./scripts/provision/macos-lima-hstack-smoke.sh happy-e2e`. - If you’re testing unpublished local changes, copy a packed tarball into the VM and run: - `HSTACK_TGZ=./happier-dev-stack-*.tgz /tmp/linux-ubuntu-e2e.sh`. + `HSTACK_TGZ=./happier-dev-stack-*.tgz /tmp/linux-ubuntu-hstack-smoke.sh`. ### 2) Create + configure a VM (recommended script) -On your macOS host (this repo): +On your macOS host (this repo, from `apps/stack/`): ```bash -./scripts/provision/macos-lima-happy-vm.sh happy-test +./scripts/provision/macos-lima-vm.sh happy-test ``` This creates the VM if needed and configures **localhost port forwarding** for the port ranges used by our VM defaults. @@ -49,7 +49,7 @@ It also sets a higher default VM memory size (to avoid Expo/Metro getting OOM-ki Override if needed: ```bash -LIMA_MEMORY=12GiB ./scripts/provision/macos-lima-happy-vm.sh happy-test +LIMA_MEMORY=12GiB ./scripts/provision/macos-lima-vm.sh happy-test ``` Port ranges note: @@ -131,9 +131,9 @@ limactl shell happy-pr Inside the VM: ```bash -curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-review-pr.sh -o /tmp/linux-ubuntu-review-pr.sh \ - && chmod +x /tmp/linux-ubuntu-review-pr.sh \ - && /tmp/linux-ubuntu-review-pr.sh +curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-provision.sh -o /tmp/linux-ubuntu-provision.sh \ + && chmod +x /tmp/linux-ubuntu-provision.sh \ + && /tmp/linux-ubuntu-provision.sh --profile=happier ``` ### 3b) (Optional) Run the hstack dev setup wizard diff --git a/apps/stack/scripts/ghops.mjs b/apps/stack/scripts/ghops.mjs new file mode 100644 index 000000000..9e1a64cfa --- /dev/null +++ b/apps/stack/scripts/ghops.mjs @@ -0,0 +1,80 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; + +function printHelp() { + process.stdout.write(` +ghops: run GitHub CLI as the Happier bot + +Usage: + yarn ghops <gh-subcommand> [...args] + +Required: + HAPPIER_GITHUB_BOT_TOKEN Fine-grained PAT for the bot account. + +Optional: + HAPPIER_GHOPS_GH_PATH Path to the 'gh' executable (default: "gh") + HAPPIER_GHOPS_CONFIG_DIR Override GH_CONFIG_DIR (default: <repo>/.happier/local/ghops/gh) + +Behavior: + - Forces GH_TOKEN from HAPPIER_GITHUB_BOT_TOKEN (no fallback to stored gh auth) + - Disables interactive prompts (GH_PROMPT_DISABLED=1) + - Uses an isolated GH_CONFIG_DIR by default + +Examples: + yarn ghops api user + yarn ghops api repos/happier-dev/happier/issues -f title="Bug" -f body="..." + yarn ghops issue create --repo happier-dev/happier --title "Bug" --body "..." + yarn ghops project item-add 1 --owner happier-dev --url https://github.com/happier-dev/happier/issues/43 +`.trimStart()); +} + +function resolveRepoRoot(cwd) { + const res = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' }); + if (res.status !== 0) return resolve(cwd); + const out = String(res.stdout ?? '').trim(); + return out ? resolve(out) : resolve(cwd); +} + +function resolvePath(repoRoot, maybePath) { + const trimmed = String(maybePath ?? '').trim(); + if (!trimmed) return null; + return isAbsolute(trimmed) ? trimmed : resolve(repoRoot, trimmed); +} + +function main() { + const args = process.argv.slice(2); + if (args.length === 0 || args.includes('--help') || args[0] === 'help') { + printHelp(); + process.exit(0); + } + + const token = String(process.env.HAPPIER_GITHUB_BOT_TOKEN ?? '').trim(); + if (!token) { + process.stderr.write('[ghops] missing required env var: HAPPIER_GITHUB_BOT_TOKEN\n'); + process.exit(2); + } + + const ghPath = String(process.env.HAPPIER_GHOPS_GH_PATH ?? '').trim() || 'gh'; + const repoRoot = resolveRepoRoot(process.cwd()); + const configDir = + resolvePath(repoRoot, process.env.HAPPIER_GHOPS_CONFIG_DIR) ?? join(repoRoot, '.happier', 'local', 'ghops', 'gh'); + + mkdirSync(configDir, { recursive: true }); + + const env = { + ...process.env, + GH_TOKEN: token, + GH_PROMPT_DISABLED: '1', + GH_CONFIG_DIR: configDir, + }; + + const res = spawnSync(ghPath, args, { stdio: 'inherit', env }); + if (res.error) { + process.stderr.write(`[ghops] failed to run gh (${ghPath}): ${String(res.error?.message ?? res.error)}\n`); + process.exit(1); + } + process.exit(res.status ?? 1); +} + +main(); diff --git a/apps/stack/scripts/ghops.test.mjs b/apps/stack/scripts/ghops.test.mjs new file mode 100644 index 000000000..4ebc351cc --- /dev/null +++ b/apps/stack/scripts/ghops.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const ghopsPath = fileURLToPath(new URL('./ghops.mjs', import.meta.url)); +const repoRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url))); + +function runGhop(args, env = {}) { + return spawnSync(process.execPath, [ghopsPath, ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + encoding: 'utf8', + }); +} + +test('prints help without requiring a token', () => { + const res = runGhop(['--help'], { + HAPPIER_GITHUB_BOT_TOKEN: '', + GH_TOKEN: '', + GITHUB_TOKEN: '', + }); + assert.equal(res.status, 0); + assert.match(res.stdout, /HAPPIER_GITHUB_BOT_TOKEN/); + assert.doesNotMatch(res.stdout, /\.project\//); + assert.match(res.stdout, /\.happier\/local\/ghops\/gh/); +}); + +test('fails closed when token is missing', () => { + const res = runGhop(['api', 'user'], { + HAPPIER_GITHUB_BOT_TOKEN: '', + GH_TOKEN: '', + GITHUB_TOKEN: '', + }); + assert.notEqual(res.status, 0); + assert.match(res.stderr, /HAPPIER_GITHUB_BOT_TOKEN/); +}); + +test('forwards args and forces gh auth via HAPPIER_GITHUB_BOT_TOKEN', () => { + const dir = mkdtempSync(join(tmpdir(), 'ghops-test-')); + const fakeGh = join(dir, 'fake-gh'); + const configDir = join(dir, 'gh-config'); + + writeFileSync( + fakeGh, + `#!/usr/bin/env node +const payload = { + argv: process.argv.slice(2), + env: { + GH_TOKEN: process.env.GH_TOKEN ?? null, + GH_PROMPT_DISABLED: process.env.GH_PROMPT_DISABLED ?? null, + GH_CONFIG_DIR: process.env.GH_CONFIG_DIR ?? null, + }, +}; +process.stdout.write(JSON.stringify(payload)); +`, + 'utf8', + ); + chmodSync(fakeGh, 0o755); + + const token = 'test-bot-token'; + const res = runGhop(['api', 'repos/happier-dev/happier'], { + HAPPIER_GITHUB_BOT_TOKEN: token, + HAPPIER_GHOPS_GH_PATH: fakeGh, + HAPPIER_GHOPS_CONFIG_DIR: configDir, + GH_TOKEN: 'personal-token-should-not-be-used', + }); + + assert.equal(res.status, 0, res.stderr); + const out = JSON.parse(res.stdout); + assert.deepEqual(out.argv, ['api', 'repos/happier-dev/happier']); + assert.equal(out.env.GH_TOKEN, token); + assert.equal(out.env.GH_PROMPT_DISABLED, '1'); + assert.equal(out.env.GH_CONFIG_DIR, configDir); +}); diff --git a/apps/stack/scripts/logs.mjs b/apps/stack/scripts/logs.mjs new file mode 100644 index 000000000..8ceb023dc --- /dev/null +++ b/apps/stack/scripts/logs.mjs @@ -0,0 +1,286 @@ +import './utils/env/env.mjs'; +import { spawn } from 'node:child_process'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { parseArgs } from './utils/cli/args.mjs'; +import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs'; +import { getDefaultAutostartPaths, getStackName, resolveStackBaseDir } from './utils/paths/paths.mjs'; +import { getStackRuntimeStatePath, readStackRuntimeStateFile } from './utils/stack/runtime_state.mjs'; +import { readLastLines } from './utils/fs/tail.mjs'; +import { banner, bullets, cmd as cmdFmt, kv, sectionTitle } from './utils/ui/layout.mjs'; +import { cyan, dim, green, yellow } from './utils/ui/ansi.mjs'; +import { run } from './utils/proc/proc.mjs'; +import { getSystemdUnitInfo } from './utils/paths/paths.mjs'; + +function coerceInt(raw, fallback) { + const s = String(raw ?? '').trim(); + const n = s ? Number(s) : null; + if (Number.isFinite(n) && n > 0) return Math.floor(n); + return fallback; +} + +function coerceComponent(raw) { + const v = String(raw ?? '').trim().toLowerCase(); + if (!v) return 'auto'; + if (v === 'all' || v === '*') return 'all'; + if (v === 'runner') return 'runner'; + if (v === 'server') return 'server'; + if (v === 'expo' || v === 'metro') return 'expo'; + if (v === 'ui' || v === 'gateway') return 'ui'; + if (v === 'daemon') return 'daemon'; + if (v === 'service') return 'service'; + if (v === 'auto') return 'auto'; + return 'auto'; +} + +function resolveLatestFileBySuffix(dir, suffix) { + try { + if (!dir || !existsSync(dir)) return null; + const entries = readdirSync(dir); + const candidates = []; + for (const name of entries) { + if (!name.endsWith(suffix)) continue; + const path = join(dir, name); + try { + const st = statSync(path); + if (!st.isFile()) continue; + candidates.push({ path, mtimeMs: st.mtimeMs }); + } catch { + // ignore + } + } + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + return candidates[0]?.path ?? null; + } catch { + return null; + } +} + +function existingFile(path) { + try { + return path && existsSync(path) ? path : null; + } catch { + return null; + } +} + +function resolveStackLogPaths({ env, stackName }) { + const { baseDir } = resolveStackBaseDir(stackName, env); + const logsDir = join(baseDir, 'logs'); + const cliHomeDir = (env.HAPPIER_STACK_CLI_HOME_DIR ?? '').toString().trim() || join(baseDir, 'cli'); + const cliLogsDir = join(cliHomeDir, 'logs'); + + const service = getDefaultAutostartPaths(env); + const serverLog = join(logsDir, 'server.log'); + const expoLog = join(logsDir, 'expo.log'); + const uiLog = join(logsDir, 'ui.log'); + + return { + baseDir, + logsDir, + cliHomeDir, + cliLogsDir, + runner: null, // filled from runtime when available + server: serverLog, + expo: expoLog, + ui: uiLog, + daemon: resolveLatestFileBySuffix(cliLogsDir, '-daemon.log'), + serviceOut: service.stdoutPath, + serviceErr: service.stderrPath, + systemd: getSystemdUnitInfo({ env, mode: (env.HAPPIER_STACK_SERVICE_MODE ?? '').toString().trim() || 'user' }), + }; +} + +function selectAutoComponent(paths) { + if (existingFile(paths.runner)) return 'runner'; + if (existingFile(paths.server) || existingFile(paths.expo) || existingFile(paths.ui)) return 'all'; + if (existingFile(paths.daemon)) return 'daemon'; + return 'service'; +} + +function selectedPathsForComponent(component, paths) { + const c = coerceComponent(component); + if (c === 'runner') return existingFile(paths.runner) ? [paths.runner] : []; + if (c === 'server') return existingFile(paths.server) ? [paths.server] : []; + if (c === 'expo') return existingFile(paths.expo) ? [paths.expo] : []; + if (c === 'ui') return existingFile(paths.ui) ? [paths.ui] : []; + if (c === 'daemon') return existingFile(paths.daemon) ? [paths.daemon] : []; + if (c === 'service') { + const out = []; + const err = existingFile(paths.serviceErr); + const o = existingFile(paths.serviceOut); + if (err) out.push(err); + if (o) out.push(o); + return out; + } + if (c === 'all') { + // Prefer runner log when present (it typically contains the full multiplexed output). + const runner = existingFile(paths.runner); + if (runner) return [runner]; + const out = []; + for (const p of [paths.server, paths.expo, paths.ui, paths.daemon, paths.serviceErr, paths.serviceOut]) { + const e = existingFile(p); + if (e) out.push(e); + } + return out; + } + const auto = selectAutoComponent(paths); + return selectedPathsForComponent(auto, paths); +} + +async function printLastLines({ label, path, lines }) { + const tail = await readLastLines(path, lines).catch(() => ''); + process.stdout.write(`${sectionTitle(label)}\n`); + process.stdout.write(`${dim('path:')} ${path}\n`); + if (!tail.trim()) { + process.stdout.write(`${dim('(no output)')}\n\n`); + return; + } + process.stdout.write(`${dim('---')}\n`); + process.stdout.write(tail.trimEnd() + '\n'); + process.stdout.write(`${dim('---')}\n\n`); +} + +async function tailFiles({ paths }) { + // Cross-platform: use tail where available; Linux service logs (journalctl) are handled separately. + const child = spawn('tail', ['-f', ...paths], { stdio: 'inherit' }); + await new Promise((resolve) => child.on('exit', resolve)); +} + +async function main() { + const argv = process.argv.slice(2); + const { flags, kv } = parseArgs(argv); + const json = wantsJson(argv, { flags }); + + const helpText = [ + banner('logs', { subtitle: 'Stream or inspect stack logs (runner/server/expo/daemon/service).' }), + '', + sectionTitle('usage:'), + ` ${cyan('hstack logs')} [--component=auto|all|runner|server|expo|ui|daemon|service] [--lines N] [--follow] [--json]`, + ` ${cyan('hstack logs')} tail [--component=...] [--lines N]`, + '', + sectionTitle('notes:'), + bullets([ + `Default component is ${cyan('auto')}: prefers runner when available, otherwise server/expo/ui, then daemon, then service.`, + `Server/Expo/UI component logs are written to ${cyan('<stack>/logs/<component>.log')} when log teeing is enabled.`, + `Service logs reflect the autostart service (launchd/systemd) output, not the dev runner.`, + ]), + '', + sectionTitle('examples:'), + bullets([ + cmdFmt('hstack logs --component=all --follow'), + cmdFmt('hstack logs --component=server --lines 200'), + cmdFmt('hstack logs tail --component=expo'), + ]), + ].join('\n'); + + if (wantsHelp(argv, { flags })) { + printResult({ json, data: { ok: true, usage: 'hstack logs [--component=...] [--follow] [--lines N] [--json]' }, text: helpText }); + return; + } + + const positionals = argv.filter((a) => a && !a.startsWith('--')); + const wantsTailPositional = (positionals[0] ?? '').toString().trim().toLowerCase() === 'tail'; + + const follow = + flags.has('--follow') || + flags.has('-f') || + wantsTailPositional || + (kv.get('--follow') ?? '').toString().trim() === '1'; + const noFollow = flags.has('--no-follow'); + const effectiveFollow = noFollow ? false : Boolean(follow); + + const componentRaw = + kv.get('--component') ?? + kv.get('--source') ?? + kv.get('--stream') ?? + kv.get('--kind') ?? + ''; + const component = coerceComponent(componentRaw); + const lines = coerceInt(kv.get('--lines') ?? process.env.HAPPIER_STACK_LOG_LINES ?? '', 120); + + const env = process.env; + const stackName = getStackName(env); + const runtimePath = getStackRuntimeStatePath(stackName); + const runtime = await readStackRuntimeStateFile(runtimePath).catch(() => null); + + const paths = resolveStackLogPaths({ env, stackName }); + paths.runner = existingFile(String(runtime?.logs?.runner ?? '').trim()); + + const selectedComponent = component === 'auto' ? selectAutoComponent(paths) : component; + const selected = selectedPathsForComponent(selectedComponent, paths); + + if (json) { + printResult({ + json, + data: { + ok: true, + stackName, + baseDir: paths.baseDir, + runtimePath, + sources: { + runner: { path: paths.runner, exists: Boolean(existingFile(paths.runner)) }, + server: { path: paths.server, exists: Boolean(existingFile(paths.server)) }, + expo: { path: paths.expo, exists: Boolean(existingFile(paths.expo)) }, + ui: { path: paths.ui, exists: Boolean(existingFile(paths.ui)) }, + daemon: { path: paths.daemon, exists: Boolean(existingFile(paths.daemon)) }, + service: { stdoutPath: paths.serviceOut, stderrPath: paths.serviceErr }, + }, + selected: { component: selectedComponent, follow: effectiveFollow, lines, paths: selected }, + }, + text: '', + }); + return; + } + + if (!selected.length) { + const hint = selectedComponent === 'runner' + ? `No runner log recorded in ${runtimePath}. If you started a stack in the background, rerun it with --background; otherwise use ${cmdFmt('hstack tui')} or ${cmdFmt('hstack logs --component=service')}.` + : `No log files found for component=${selectedComponent}.`; + console.log(banner('logs', { subtitle: `stack=${stackName}` })); + console.log(''); + console.log(bullets([`${yellow('!')} ${hint}`])); + return; + } + + console.log(banner('logs', { subtitle: `stack=${stackName}` })); + console.log(bullets([kv('component:', selectedComponent), kv('follow:', effectiveFollow ? green('yes') : dim('no'))])); + console.log(''); + + // Special-case: Linux service logs are in journald, not files, and `tail` on out/err logs isn't meaningful there. + if (selectedComponent === 'service' && process.platform === 'linux') { + const unit = paths.systemd.unitName; + if (!unit) { + console.warn('[logs] missing systemd unit name'); + return; + } + if (effectiveFollow) { + await run('journalctl', [...paths.systemd.journalctlArgsPrefix, '-u', unit, '-f']); + } else { + await run('journalctl', [...paths.systemd.journalctlArgsPrefix, '-u', unit, '-n', String(lines), '--no-pager']); + } + return; + } + + if (effectiveFollow) { + await tailFiles({ paths: selected }); + return; + } + + if (selected.length === 1) { + await printLastLines({ label: selectedComponent, path: selected[0], lines }); + return; + } + + for (const p of selected) { + // eslint-disable-next-line no-await-in-loop + await printLastLines({ label: selectedComponent, path: p, lines }); + } +} + +main().catch((err) => { + console.error('[logs] failed:', err); + process.exit(1); +}); + diff --git a/apps/stack/scripts/logs_cmd.test.mjs b/apps/stack/scripts/logs_cmd.test.mjs new file mode 100644 index 000000000..41265396d --- /dev/null +++ b/apps/stack/scripts/logs_cmd.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const logsScriptPath = fileURLToPath(new URL('./logs.mjs', import.meta.url)); + +function runNode(args, { cwd, env }) { + return new Promise((resolve, reject) => { + const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d) => (stdout += String(d))); + proc.stderr.on('data', (d) => (stderr += String(d))); + proc.on('error', reject); + proc.on('exit', (code, signal) => resolve({ code: code ?? (signal ? 1 : 0), signal, stdout, stderr })); + }); +} + +test('hstack logs --json resolves stack-scoped sources and auto-selects runner when present', async () => { + const root = mkdtempSync(join(tmpdir(), 'happier-logs-cmd-')); + const stackName = 'exp1'; + const baseDir = join(root, stackName); + const logsDir = join(baseDir, 'logs'); + const cliLogsDir = join(baseDir, 'cli', 'logs'); + mkdirSync(logsDir, { recursive: true }); + mkdirSync(cliLogsDir, { recursive: true }); + + const runnerLog = join(logsDir, 'runner.log'); + writeFileSync(runnerLog, '[runner] hello\n', 'utf-8'); + writeFileSync(join(logsDir, 'server.log'), '[server] hi\n', 'utf-8'); + writeFileSync(join(logsDir, 'expo.log'), '[expo] hi\n', 'utf-8'); + writeFileSync(join(cliLogsDir, 'x-daemon.log'), '[daemon] hi\n', 'utf-8'); + + const runtimePath = join(baseDir, 'stack.runtime.json'); + writeFileSync(runtimePath, JSON.stringify({ version: 1, stackName, logs: { runner: runnerLog } }, null, 2) + '\n', 'utf-8'); + + const res = await runNode( + [logsScriptPath, '--json'], + { + cwd: process.cwd(), + env: { + ...process.env, + HAPPIER_STACK_STORAGE_DIR: root, + HAPPIER_STACK_STACK: stackName, + }, + } + ); + assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + const data = JSON.parse(res.stdout); + assert.equal(data.ok, true); + assert.equal(data.stackName, stackName); + assert.equal(data.selected.component, 'runner'); + assert.ok(Array.isArray(data.selected.paths)); + assert.equal(data.selected.paths[0], runnerLog); +}); + +test('hstack logs --component=server --json selects server log path when present', async () => { + const root = mkdtempSync(join(tmpdir(), 'happier-logs-cmd-')); + const stackName = 'exp1'; + const baseDir = join(root, stackName); + const logsDir = join(baseDir, 'logs'); + mkdirSync(logsDir, { recursive: true }); + + const serverLog = join(logsDir, 'server.log'); + writeFileSync(serverLog, '[server] hi\n', 'utf-8'); + + const res = await runNode( + [logsScriptPath, '--component=server', '--json'], + { + cwd: process.cwd(), + env: { + ...process.env, + HAPPIER_STACK_STORAGE_DIR: root, + HAPPIER_STACK_STACK: stackName, + }, + } + ); + assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + const data = JSON.parse(res.stdout); + assert.equal(data.ok, true); + assert.equal(data.selected.component, 'server'); + assert.deepEqual(data.selected.paths, [serverLog]); +}); diff --git a/apps/stack/scripts/provision/linux-ubuntu-e2e.sh b/apps/stack/scripts/provision/linux-ubuntu-hstack-smoke.sh similarity index 56% rename from apps/stack/scripts/provision/linux-ubuntu-e2e.sh rename to apps/stack/scripts/provision/linux-ubuntu-hstack-smoke.sh index d2a57e647..cfcfad908 100755 --- a/apps/stack/scripts/provision/linux-ubuntu-e2e.sh +++ b/apps/stack/scripts/provision/linux-ubuntu-hstack-smoke.sh @@ -1,34 +1,29 @@ #!/usr/bin/env bash set -euo pipefail -# End-to-end (best-effort) smoke test for hstack on Ubuntu. +# Best-effort smoke test for `hstack` on Ubuntu using an isolated sandbox. # -# Intended usage (inside a Lima VM): -# curl -fsSL https://raw.githubusercontent.com/<org>/<repo>/<ref>/apps/stack/scripts/provision/linux-ubuntu-review-pr.sh -o /tmp/linux-ubuntu-review-pr.sh \ -# && chmod +x /tmp/linux-ubuntu-review-pr.sh \ -# && /tmp/linux-ubuntu-review-pr.sh +# Intended usage (inside a VM): +# curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-provision.sh -o /tmp/linux-ubuntu-provision.sh \ +# && chmod +x /tmp/linux-ubuntu-provision.sh \ +# && /tmp/linux-ubuntu-provision.sh --profile=happier # -# curl -fsSL https://raw.githubusercontent.com/<org>/<repo>/<ref>/apps/stack/scripts/provision/linux-ubuntu-e2e.sh -o /tmp/linux-ubuntu-e2e.sh \ -# && chmod +x /tmp/linux-ubuntu-e2e.sh \ -# && HSTACK_VERSION=latest /tmp/linux-ubuntu-e2e.sh -# -# Notes: -# - This uses `npx` by default (no reliance on global installs). -# - All state is isolated under a hstack sandbox dir so it can be deleted cleanly. -# - Authentication / Tailscale / autostart / menubar are intentionally skipped. +# curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-hstack-smoke.sh -o /tmp/linux-ubuntu-hstack-smoke.sh \ +# && chmod +x /tmp/linux-ubuntu-hstack-smoke.sh \ +# && HSTACK_VERSION=latest /tmp/linux-ubuntu-hstack-smoke.sh # # Env overrides: # - HSTACK_VERSION: npm dist-tag or semver for @happier-dev/stack (default: latest) # - HSTACK_TGZ: path to a local @happier-dev/stack tarball inside the VM (overrides HSTACK_VERSION) -# - HSTACK_E2E_DIR: where to store the sandbox + logs (default: /tmp/hstack-e2e-<timestamp>) -# - HSTACK_E2E_KEEP: set to 1 to keep the sandbox dir on exit (default: 0) +# - HSTACK_SMOKE_DIR: where to store the sandbox + logs (default: /tmp/hstack-smoke-<timestamp>) +# - HSTACK_SMOKE_KEEP: set to 1 to keep the sandbox dir on exit (default: 0) require_cmd() { command -v "$1" >/dev/null 2>&1 } fail() { - echo "[e2e] failed: $*" >&2 + echo "[smoke] failed: $*" >&2 exit 1 } @@ -47,12 +42,12 @@ done STACK_VERSION="${HSTACK_VERSION:-latest}" STACK_TGZ="${HSTACK_TGZ:-}" -E2E_DIR="${HSTACK_E2E_DIR:-/tmp/hstack-e2e-$(timestamp)}" -KEEP="${HSTACK_E2E_KEEP:-0}" +SMOKE_DIR="${HSTACK_SMOKE_DIR:-/tmp/hstack-smoke-$(timestamp)}" +KEEP="${HSTACK_SMOKE_KEEP:-0}" -SANDBOX_DIR="${E2E_DIR}/sandbox" -NPM_CACHE="${E2E_DIR}/npm-cache" -LOG_DIR="${E2E_DIR}/logs" +SANDBOX_DIR="${SMOKE_DIR}/sandbox" +NPM_CACHE="${SMOKE_DIR}/npm-cache" +LOG_DIR="${SMOKE_DIR}/logs" mkdir -p "$LOG_DIR" "$NPM_CACHE" @@ -72,19 +67,19 @@ hstack() { cleanup() { if [[ "$KEEP" == "1" ]]; then echo "" - echo "[e2e] keeping sandbox dir: $E2E_DIR" + echo "[smoke] keeping dir: $SMOKE_DIR" return fi - rm -rf "$E2E_DIR" >/dev/null 2>&1 || true + rm -rf "$SMOKE_DIR" >/dev/null 2>&1 || true } trap cleanup EXIT say "system info" -echo "[e2e] node: $(node --version)" -echo "[e2e] npm: $(npm --version)" -echo "[e2e] git: $(git --version)" -echo "[e2e] jq: $(jq --version)" -echo "[e2e] pkg: @happier-dev/stack@${STACK_VERSION}" +echo "[smoke] node: $(node --version)" +echo "[smoke] npm: $(npm --version)" +echo "[smoke] git: $(git --version)" +echo "[smoke] jq: $(jq --version)" +echo "[smoke] pkg: @happier-dev/stack@${STACK_VERSION}" say "hstack help (sanity)" hstack --help >/dev/null @@ -92,7 +87,6 @@ hstack --help >/dev/null say "hstack where --json (sandbox wiring)" WHERE_JSON="$(hstack --sandbox-dir "$SANDBOX_DIR" where --json | tee "$LOG_DIR/where.json")" echo "$WHERE_JSON" | jq -e '.sandbox.enabled == true' >/dev/null || fail "expected sandbox.enabled=true" -echo "$WHERE_JSON" | jq -e '.repoDir | startswith("/tmp/") or startswith("/var/") or startswith("/home/")' >/dev/null || true say "selfhost setup (no auth/tailscale/autostart/menubar)" export HAPPIER_STACK_UPDATE_CHECK=0 @@ -111,7 +105,7 @@ INTERNAL_URL="$(echo "$START_JSON" | jq -r '.internalServerUrl')" if [[ -z "$INTERNAL_URL" || "$INTERNAL_URL" == "null" ]]; then fail "missing internalServerUrl from start --json" fi -echo "[e2e] internal url: $INTERNAL_URL" +echo "[smoke] internal url: $INTERNAL_URL" say "health check" curl -fsS "${INTERNAL_URL}/health" | tee "$LOG_DIR/health.json" | jq -e '.status == "ok"' >/dev/null @@ -121,12 +115,12 @@ HTML_HEAD="$(curl -fsS "${INTERNAL_URL}/" | head -n 5 || true)" echo "$HTML_HEAD" | tee "$LOG_DIR/ui.head.txt" | grep -Eqi '<!doctype html|<html' || fail "expected HTML from ${INTERNAL_URL}/" say "worktree smoke (monorepo-only)" -# Create a throwaway worktree based on the default remote (keeps this test stable even if upstream remotes vary). -hstack --sandbox-dir "$SANDBOX_DIR" wt new "tmp/e2e-$(timestamp)" --from=origin --use --json | tee "$LOG_DIR/wt-new.json" >/dev/null +hstack --sandbox-dir "$SANDBOX_DIR" wt new "tmp/smoke-$(timestamp)" --from=origin --use --json | tee "$LOG_DIR/wt-new.json" >/dev/null hstack --sandbox-dir "$SANDBOX_DIR" wt status --json | tee "$LOG_DIR/wt-status.json" >/dev/null say "stop main stack (clean shutdown)" hstack --sandbox-dir "$SANDBOX_DIR" stop --yes --aggressive --sweep-owned --no-service 2>&1 | tee "$LOG_DIR/stop-main.log" say "done" -echo "[e2e] ok" +echo "[smoke] ok" + diff --git a/apps/stack/scripts/provision/linux-ubuntu-provision.sh b/apps/stack/scripts/provision/linux-ubuntu-provision.sh new file mode 100755 index 000000000..0a16c3b4e --- /dev/null +++ b/apps/stack/scripts/provision/linux-ubuntu-provision.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Provision an Ubuntu machine with *dependencies only* so you can install/run Happier tools manually. +# This script intentionally does NOT install Happier/hstack itself. +# +# Intended usage (inside a VM): +# curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-provision.sh -o /tmp/linux-ubuntu-provision.sh \ +# && chmod +x /tmp/linux-ubuntu-provision.sh \ +# && /tmp/linux-ubuntu-provision.sh --profile=happier +# +# Profiles: +# - happier : build tools + Node + Corepack/Yarn (default) +# - installer : minimal base tooling (curl/ca-certs) to test the official installer on a mostly-empty box +# - bare : do nothing (useful if you explicitly want an unprovisioned VM) +# +# Env overrides: +# - HAPPIER_PROVISION_NODE_MAJOR (default: 24) +# - HAPPIER_PROVISION_YARN_VERSION (default: 1.22.22) + +usage() { + cat <<'EOF' +Usage: + ./linux-ubuntu-provision.sh [--profile=happier|installer|bare] + +Examples: + ./linux-ubuntu-provision.sh --profile=happier + ./linux-ubuntu-provision.sh --profile=installer + ./linux-ubuntu-provision.sh --profile=bare +EOF +} + +PROFILE="happier" +for arg in "$@"; do + case "$arg" in + -h|--help) + usage + exit 0 + ;; + --profile=*) + PROFILE="${arg#--profile=}" + ;; + *) + echo "[provision] unknown argument: ${arg}" >&2 + usage >&2 + exit 2 + ;; + esac +done + +require_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +as_root() { + if [[ "$(id -u)" == "0" ]]; then + "$@" + return + fi + if require_cmd sudo; then + sudo "$@" + return + fi + echo "[provision] missing sudo; re-run as root" >&2 + exit 1 +} + +say() { + echo "" + echo "[provision] $*" +} + +NODE_MAJOR="${HAPPIER_PROVISION_NODE_MAJOR:-24}" +YARN_VERSION="${HAPPIER_PROVISION_YARN_VERSION:-1.22.22}" + +case "${PROFILE}" in + happier|installer|bare) ;; + *) + echo "[provision] invalid --profile: ${PROFILE}" >&2 + usage >&2 + exit 2 + ;; +esac + +if [[ "${PROFILE}" == "bare" ]]; then + say "profile=bare (no changes)" + echo "" + echo "[provision] done." + exit 0 +fi + +say "updating apt" +as_root apt-get update -y + +if [[ "${PROFILE}" == "installer" ]]; then + say "installing minimal packages (installer profile)" + as_root apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + xz-utils + echo "" + echo "[provision] done." + echo "" + echo "Next (example):" + echo " curl -fsSL https://happier.dev/install | bash" + exit 0 +fi + +say "installing base packages" +as_root apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gnupg \ + jq \ + xz-utils \ + build-essential \ + python3 + +if ! require_cmd node; then + say "installing Node.js (NodeSource ${NODE_MAJOR}.x)" + as_root bash -lc "curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash -" + as_root apt-get install -y nodejs +fi + +say "node: $(node --version)" + +if ! require_cmd corepack; then + echo "[provision] corepack not found (expected with Node >=16)." >&2 + exit 1 +fi + +say "enabling Corepack shims (root)" +as_root corepack enable + +say "preparing Yarn ${YARN_VERSION} (root; system cache)" +as_root mkdir -p /usr/local/share/corepack +as_root env COREPACK_HOME=/usr/local/share/corepack corepack prepare "yarn@${YARN_VERSION}" --activate + +say "yarn: $(yarn --version)" + +echo "" +echo "[provision] done." +echo "" +echo "Next:" +echo " npx --yes -p @happier-dev/stack@latest hstack setup --profile=dev --bind=loopback" + diff --git a/apps/stack/scripts/provision/linux-ubuntu-provision.test.mjs b/apps/stack/scripts/provision/linux-ubuntu-provision.test.mjs new file mode 100644 index 000000000..b7c8316d0 --- /dev/null +++ b/apps/stack/scripts/provision/linux-ubuntu-provision.test.mjs @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, chmod, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +async function readIfExists(path) { + try { + return await readFile(path, 'utf-8'); + } catch { + return ''; + } +} + +test('linux provision (happier profile) runs corepack enable as root', async () => { + const root = await mkdtemp(join(tmpdir(), 'hstack-linux-provision-test-')); + const binDir = join(root, 'bin'); + const logDir = join(root, 'logs'); + const { mkdir } = await import('node:fs/promises'); + await mkdir(binDir, { recursive: true }); + await mkdir(logDir, { recursive: true }); + + const corepackLog = join(logDir, 'corepack.log'); + const aptLog = join(logDir, 'apt.log'); + + const idPath = join(binDir, 'id'); + await writeFile( + idPath, + ['#!/usr/bin/env bash', 'if [[ "${1:-}" == "-u" ]]; then echo 1000; else echo "uid=1000"; fi'].join('\n') + '\n', + 'utf-8' + ); + await chmod(idPath, 0o755); + + const sudoPath = join(binDir, 'sudo'); + await writeFile( + sudoPath, + ['#!/usr/bin/env bash', 'export RUN_AS_ROOT=1', 'exec "$@"'].join('\n') + '\n', + 'utf-8' + ); + await chmod(sudoPath, 0o755); + + const aptPath = join(binDir, 'apt-get'); + await writeFile( + aptPath, + [ + '#!/usr/bin/env bash', + `echo "apt-get $*" >> ${JSON.stringify(aptLog)}`, + 'exit 0', + ].join('\n') + '\n', + 'utf-8' + ); + await chmod(aptPath, 0o755); + + const mkdirPath = join(binDir, 'mkdir'); + await writeFile( + mkdirPath, + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + 'for a in "$@"; do', + ' if [[ "$a" == "/usr/local/share/corepack" ]]; then', + ' exit 0', + ' fi', + 'done', + 'exec /bin/mkdir "$@"', + ].join('\n') + '\n', + 'utf-8' + ); + await chmod(mkdirPath, 0o755); + + const nodePath = join(binDir, 'node'); + await writeFile(nodePath, ['#!/usr/bin/env bash', 'echo "v24.0.0"'].join('\n') + '\n', 'utf-8'); + await chmod(nodePath, 0o755); + + const corepackPath = join(binDir, 'corepack'); + await writeFile( + corepackPath, + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + 'echo "corepack $* root=${RUN_AS_ROOT:-0}" >> ' + JSON.stringify(corepackLog), + 'if [[ "${1:-}" == "enable" && "${RUN_AS_ROOT:-0}" != "1" ]]; then', + ' echo "enable must run as root" >&2', + ' exit 13', + 'fi', + 'exit 0', + ].join('\n') + '\n', + 'utf-8' + ); + await chmod(corepackPath, 0o755); + + const yarnPath = join(binDir, 'yarn'); + await writeFile(yarnPath, ['#!/usr/bin/env bash', 'echo "1.22.22"'].join('\n') + '\n', 'utf-8'); + await chmod(yarnPath, 0o755); + + const scriptPath = join(__dirname, 'linux-ubuntu-provision.sh'); + const res = spawnSync('bash', [scriptPath, '--profile=happier'], { + cwd: root, + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` }, + encoding: 'utf-8', + }); + + assert.equal(res.status, 0, `expected exit 0\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + + const corepackOut = await readIfExists(corepackLog); + assert.match(corepackOut, /corepack enable root=1/, 'expected corepack enable to be invoked via sudo/as_root'); + assert.ok(!corepackOut.includes('corepack enable root=0'), 'expected corepack enable not to run unprivileged'); + + const aptOut = await readIfExists(aptLog); + assert.match(aptOut, /apt-get update/, 'expected apt-get update to run'); + assert.match(aptOut, /apt-get install/, 'expected apt-get install to run'); +}); + +test('linux provision (installer profile) does not touch node/corepack', async () => { + const root = await mkdtemp(join(tmpdir(), 'hstack-linux-provision-installer-test-')); + const binDir = join(root, 'bin'); + const logDir = join(root, 'logs'); + const { mkdir } = await import('node:fs/promises'); + await mkdir(binDir, { recursive: true }); + await mkdir(logDir, { recursive: true }); + + const corepackLog = join(logDir, 'corepack.log'); + + const idPath = join(binDir, 'id'); + await writeFile(idPath, ['#!/usr/bin/env bash', 'echo 1000'].join('\n') + '\n', 'utf-8'); + await chmod(idPath, 0o755); + + const sudoPath = join(binDir, 'sudo'); + await writeFile(sudoPath, ['#!/usr/bin/env bash', 'export RUN_AS_ROOT=1', 'exec "$@"'].join('\n') + '\n', 'utf-8'); + await chmod(sudoPath, 0o755); + + const aptPath = join(binDir, 'apt-get'); + await writeFile(aptPath, ['#!/usr/bin/env bash', 'exit 0'].join('\n') + '\n', 'utf-8'); + await chmod(aptPath, 0o755); + + const corepackPath = join(binDir, 'corepack'); + await writeFile( + corepackPath, + [ + '#!/usr/bin/env bash', + `echo "corepack $*" >> ${JSON.stringify(corepackLog)}`, + 'exit 0', + ].join('\n') + '\n', + 'utf-8' + ); + await chmod(corepackPath, 0o755); + + const scriptPath = join(__dirname, 'linux-ubuntu-provision.sh'); + const res = spawnSync('bash', [scriptPath, '--profile=installer'], { + cwd: root, + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` }, + encoding: 'utf-8', + }); + + assert.equal(res.status, 0, `expected exit 0\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + + const corepackOut = await readIfExists(corepackLog); + assert.equal(corepackOut.trim(), '', 'expected no corepack calls in installer profile'); +}); diff --git a/apps/stack/scripts/provision/linux-ubuntu-review-pr.sh b/apps/stack/scripts/provision/linux-ubuntu-review-pr.sh deleted file mode 100755 index a186cb2e5..000000000 --- a/apps/stack/scripts/provision/linux-ubuntu-review-pr.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Provision an Ubuntu VM for running hstack (Happier Stack) in an isolated Linux environment. -# -# Intended usage (inside a Lima VM): -# curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-review-pr.sh -o /tmp/linux-ubuntu-review-pr.sh \ -# && chmod +x /tmp/linux-ubuntu-review-pr.sh \ -# && /tmp/linux-ubuntu-review-pr.sh -# -# After provisioning, run: -# npx --yes -p @happier-dev/stack@latest hstack setup --profile=dev --bind=loopback - -require_cmd() { - command -v "$1" >/dev/null 2>&1 -} - -as_root() { - if [[ "$(id -u)" == "0" ]]; then - "$@" - return - fi - if require_cmd sudo; then - sudo "$@" - return - fi - echo "[provision] missing sudo; re-run as root" >&2 - exit 1 -} - -echo "[provision] updating apt..." -as_root apt-get update -y - -echo "[provision] installing base packages..." -as_root apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - git \ - gnupg \ - jq \ - build-essential \ - python3 - -if ! require_cmd node; then - echo "[provision] installing Node.js (NodeSource 24.x)..." - as_root bash -lc 'curl -fsSL https://deb.nodesource.com/setup_24.x | bash -' - as_root apt-get install -y nodejs -fi - -echo "[provision] node: $(node --version)" - -if ! require_cmd corepack; then - echo "[provision] corepack not found (expected with Node >=16)." - exit 1 -fi - -echo "[provision] enabling corepack + yarn..." -corepack enable -corepack prepare yarn@1.22.22 --activate -yarn --version - -echo "" -echo "[provision] done." -echo "" -echo "Next:" -echo " npx --yes -p @happier-dev/stack@latest hstack setup --profile=dev --bind=loopback" diff --git a/apps/stack/scripts/provision/macos-lima-happy-vm.sh b/apps/stack/scripts/provision/macos-lima-happy-vm.sh deleted file mode 100755 index af33b8ec5..000000000 --- a/apps/stack/scripts/provision/macos-lima-happy-vm.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Create/configure a Lima VM for testing hstack in an isolated Linux environment, -# while still opening the Expo web UI on the macOS host via localhost port forwarding -# (required for WebCrypto/secure-context APIs). -# -# Usage: -# ./scripts/provision/macos-lima-happy-vm.sh [vm-name] -# -# Defaults: -# vm-name: happy-test -# template: ubuntu-24.04 -# -# What it does: -# - creates the VM (if missing) -# - injects port forwarding rules for the hstack VM port ranges -# - restarts the VM so the rules take effect -# - prints next steps (provision script + hstack commands) - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - cat <<'EOF' -Usage: - ./scripts/provision/macos-lima-happy-vm.sh [vm-name] - -Examples: - ./scripts/provision/macos-lima-happy-vm.sh # uses "happy-test" - ./scripts/provision/macos-lima-happy-vm.sh happy # uses "happy" - ./scripts/provision/macos-lima-happy-vm.sh happy-vm # uses "happy-vm" - -Notes: -- This is intended to be run on the macOS host (not inside the VM). -- It configures localhost port forwarding so you can open http://localhost / http://*.localhost - in your macOS browser (required for WebCrypto APIs used by Expo web). -EOF - exit 0 -fi - -if [[ "$(uname -s)" != "Darwin" ]]; then - echo "[lima] expected macOS (Darwin); got: $(uname -s)" >&2 - exit 1 -fi - -if ! command -v limactl >/dev/null 2>&1; then - echo "[lima] limactl not found. Install Lima first (example: brew install lima)." >&2 - exit 1 -fi - -VM_NAME="${1:-happy-test}" -TEMPLATE="${LIMA_TEMPLATE:-ubuntu-24.04}" -LIMA_MEMORY="${LIMA_MEMORY:-8GiB}" -LIMA_VM_TYPE="${LIMA_VM_TYPE:-}" # optional: vz|qemu|krunkit (see: limactl create --list-drivers) -TEMPLATE_LOCATOR="${TEMPLATE}" -if [[ "${TEMPLATE_LOCATOR}" == template://* ]]; then - TEMPLATE_LOCATOR="template:${TEMPLATE_LOCATOR#template://}" -elif [[ "${TEMPLATE_LOCATOR}" != template:* ]]; then - TEMPLATE_LOCATOR="template:${TEMPLATE_LOCATOR}" -fi -LIMA_HOME_DIR="${LIMA_HOME:-${HOME}/.lima}" -export LIMA_HOME="${LIMA_HOME_DIR}" -LIMA_DIR="${LIMA_HOME_DIR}/${VM_NAME}" -LIMA_YAML="${LIMA_DIR}/lima.yaml" - -echo "[lima] vm: ${VM_NAME}" -echo "[lima] template: ${TEMPLATE}" -echo "[lima] memory: ${LIMA_MEMORY} (override with LIMA_MEMORY=...)" -echo "[lima] LIMA_HOME: ${LIMA_HOME_DIR}" -if [[ -n "${LIMA_VM_TYPE}" ]]; then - echo "[lima] vmType: ${LIMA_VM_TYPE}" -fi - -if [[ ! -f "${LIMA_YAML}" ]]; then - echo "[lima] creating VM..." - if [[ "${LIMA_VM_TYPE}" == "qemu" ]]; then - if ! command -v qemu-system-aarch64 >/dev/null 2>&1; then - echo "[lima] qemu vmType requested but qemu-system-aarch64 is missing." >&2 - echo "[lima] Fix: brew install qemu" >&2 - exit 1 - fi - fi - create_args=(create --name "${VM_NAME}" --tty=false) - if [[ -n "${LIMA_VM_TYPE}" ]]; then - create_args+=(--vm-type "${LIMA_VM_TYPE}") - fi - create_args+=("${TEMPLATE_LOCATOR}") - limactl "${create_args[@]}" -fi - -if [[ ! -f "${LIMA_YAML}" ]]; then - echo "[lima] expected instance config at: ${LIMA_YAML}" >&2 - exit 1 -fi - -echo "[lima] stopping VM (if running)..." -limactl stop "${VM_NAME}" >/dev/null 2>&1 || true - -echo "[lima] configuring port forwarding (localhost)..." -cp -a "${LIMA_YAML}" "${LIMA_YAML}.bak.$(date +%Y%m%d-%H%M%S)" - -VM_NAME="${VM_NAME}" LIMA_YAML="${LIMA_YAML}" LIMA_MEMORY="${LIMA_MEMORY}" python3 - <<'PY' -import os, re -from pathlib import Path - -vm_name = os.environ["VM_NAME"] -path = Path(os.environ["LIMA_YAML"]) -memory = os.environ.get("LIMA_MEMORY", "8GiB") -text = path.read_text(encoding="utf-8") - -MEM_MARK_BEGIN = "# --- hstack vm sizing (managed by hstack) ---" -MEM_MARK_END = "# --- /hstack vm sizing ---" -MARK_BEGIN = "# --- hstack port forwards (managed by hstack) ---" -MARK_END = "# --- /hstack port forwards ---" - -entries = [ - " - guestPortRange: [13000, 13999]\n hostPortRange: [13000, 13999]\n", - " - guestPortRange: [18000, 19099]\n hostPortRange: [18000, 19099]\n", -] - -mem_block = ( - f"\n{MEM_MARK_BEGIN}\n" - f'memory: "{memory}"\n' - f"{MEM_MARK_END}\n" -) - -block_as_section = ( - f"\n{MARK_BEGIN}\n" - "portForwards:\n" - + "".join(entries) + - f"{MARK_END}\n" -) - -block_as_list_items = ( - f" # --- hstack port forwards (managed by hstack) ---\n" - + "".join(entries) + - f" # --- /hstack port forwards ---\n" -) - -if MEM_MARK_BEGIN in text and MEM_MARK_END in text: - text = re.sub( - re.escape(MEM_MARK_BEGIN) + r"[\\s\\S]*?" + re.escape(MEM_MARK_END) + r"\\n?", - mem_block.strip("\n") + "\n", - text, - flags=re.MULTILINE, - ) -else: - m = re.search(r"^memory:\\s*.*$", text, flags=re.MULTILINE) - if m: - text = re.sub(r"^memory:\\s*.*$", f'memory: "{memory}"', text, flags=re.MULTILINE) - else: - text = text.rstrip() + mem_block - -if MARK_BEGIN in text and MARK_END in text: - text = re.sub( - re.escape(MARK_BEGIN) + r"[\\s\\S]*?" + re.escape(MARK_END) + r"\\n?", - block_as_section.strip("\n") + "\n", - text, - flags=re.MULTILINE, - ) -else: - m = re.search(r"^portForwards:\\s*$", text, flags=re.MULTILINE) - if m: - insert_at = m.end() - text = text[:insert_at] + "\n" + block_as_list_items + text[insert_at:] - else: - text = text.rstrip() + block_as_section - -path.write_text(text, encoding="utf-8") -print(f"[lima] updated {path} ({vm_name})") -PY - -echo "[lima] starting VM..." -limactl start "${VM_NAME}" - -cat <<EOF - -[lima] done. - -Next steps: - limactl shell ${VM_NAME} - -Inside the VM: - curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-review-pr.sh -o /tmp/linux-ubuntu-review-pr.sh \\ - && chmod +x /tmp/linux-ubuntu-review-pr.sh \\ - && /tmp/linux-ubuntu-review-pr.sh - -Then: - npx --yes -p @happier-dev/stack@latest hstack setup --profile=dev --bind=loopback - -Tip: - Open the printed URLs on your macOS host via http://localhost:<port> or http://*.localhost:<port>. - For `npx --yes -p @happier-dev/stack hstack review-pr ...` inside the VM, pass `--vm-ports` so stack ports land in the forwarded ranges. -EOF diff --git a/apps/stack/scripts/provision/macos-lima-hstack-e2e.sh b/apps/stack/scripts/provision/macos-lima-hstack-e2e.sh deleted file mode 100755 index 8dee2f3c6..000000000 --- a/apps/stack/scripts/provision/macos-lima-hstack-e2e.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Automated Lima VM E2E smoke test runner for hstack. -# -# Usage (macOS host): -# ./scripts/provision/macos-lima-hstack-e2e.sh [vm-name] -# -# Env: -# HSTACK_VERSION=latest # @happier-dev/stack version to test (default: latest) -# HSTACK_E2E_KEEP=1 # keep sandbox dir in the VM (default: 0) -# HSTACK_RAW_BASE=... # override raw github base (default: happier-dev/happier main) -# -# This script: -# - creates/configures a Lima VM (via macos-lima-happy-vm.sh) -# - provisions Node/Yarn inside the VM -# - runs a sandboxed `hstack` selfhost+worktree smoke test inside the VM - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - cat <<'EOF' -Usage: - ./scripts/provision/macos-lima-hstack-e2e.sh [vm-name] - -Examples: - ./scripts/provision/macos-lima-hstack-e2e.sh - HSTACK_VERSION=latest ./scripts/provision/macos-lima-hstack-e2e.sh happy-e2e - -Notes: -- Run on macOS (Darwin) host. -- Uses a fully isolated sandbox inside the VM (does not touch ~/.happier-stack on the VM). -EOF - exit 0 -fi - -if [[ "$(uname -s)" != "Darwin" ]]; then - echo "[lima-e2e] expected macOS (Darwin); got: $(uname -s)" >&2 - exit 1 -fi - -if ! command -v limactl >/dev/null 2>&1; then - echo "[lima-e2e] limactl not found. Install Lima first (example: brew install lima)." >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VM_NAME="${1:-happy-e2e}" - -HSTACK_VERSION="${HSTACK_VERSION:-latest}" -HSTACK_E2E_KEEP="${HSTACK_E2E_KEEP:-0}" - -pick_raw_base() { - if [[ -n "${HSTACK_RAW_BASE:-}" ]]; then - echo "${HSTACK_RAW_BASE}" - return 0 - fi - local candidates=( - "https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack" - ) - local c - for c in "${candidates[@]}"; do - if curl -fsSL "${c}/scripts/provision/linux-ubuntu-review-pr.sh" -o /dev/null >/dev/null 2>&1; then - echo "$c" - return 0 - fi - done - return 1 -} - -HSTACK_RAW_BASE="$(pick_raw_base || true)" -if [[ -z "${HSTACK_RAW_BASE}" ]]; then - echo "[lima-e2e] failed to auto-detect raw GitHub base URL for scripts." >&2 - echo "[lima-e2e] Fix: set HSTACK_RAW_BASE=https://raw.githubusercontent.com/<org>/<repo>/<ref>/apps/stack" >&2 - exit 1 -fi - -echo "[lima-e2e] vm: ${VM_NAME}" -echo "[lima-e2e] @happier-dev/stack: ${HSTACK_VERSION}" -echo "[lima-e2e] raw base: ${HSTACK_RAW_BASE}" - -echo "[lima-e2e] ensure VM exists + port forwarding..." -"${SCRIPT_DIR}/macos-lima-happy-vm.sh" "${VM_NAME}" - -echo "[lima-e2e] running provisioning + e2e inside VM..." -limactl shell "${VM_NAME}" -- bash -lc " - set -euo pipefail - echo '[vm] downloading provision + e2e scripts...' - curl -fsSL '${HSTACK_RAW_BASE}/scripts/provision/linux-ubuntu-review-pr.sh' -o /tmp/linux-ubuntu-review-pr.sh - chmod +x /tmp/linux-ubuntu-review-pr.sh - /tmp/linux-ubuntu-review-pr.sh - - curl -fsSL '${HSTACK_RAW_BASE}/scripts/provision/linux-ubuntu-e2e.sh' -o /tmp/linux-ubuntu-e2e.sh - chmod +x /tmp/linux-ubuntu-e2e.sh - - export HSTACK_VERSION='${HSTACK_VERSION}' - export HSTACK_E2E_KEEP='${HSTACK_E2E_KEEP}' - /tmp/linux-ubuntu-e2e.sh -" - -echo "" -echo "[lima-e2e] done." diff --git a/apps/stack/scripts/provision/macos-lima-hstack-smoke.sh b/apps/stack/scripts/provision/macos-lima-hstack-smoke.sh new file mode 100755 index 000000000..50d74faec --- /dev/null +++ b/apps/stack/scripts/provision/macos-lima-hstack-smoke.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Automated Lima VM smoke test runner for hstack. +# +# Usage (macOS host): +# ./scripts/provision/macos-lima-hstack-smoke.sh [vm-name] +# +# Env: +# HSTACK_VERSION=latest # @happier-dev/stack version to test (default: latest) +# HSTACK_SMOKE_KEEP=1 # keep sandbox dir in the VM (default: 0) +# HSTACK_RAW_BASE=... # override raw github base (default: happier-dev/happier main) +# HSTACK_PROVISION_PROFILE=happier # guest provisioning profile (default: happier) +# +# This script: +# - creates/configures a Lima VM (via macos-lima-vm.sh) +# - provisions dependencies inside the VM (linux-ubuntu-provision.sh) +# - runs a sandboxed `hstack` smoke test inside the VM (linux-ubuntu-hstack-smoke.sh) + +usage() { + cat <<'EOF' +Usage: + ./scripts/provision/macos-lima-hstack-smoke.sh [vm-name] + +Examples: + ./scripts/provision/macos-lima-hstack-smoke.sh + HSTACK_VERSION=latest ./scripts/provision/macos-lima-hstack-smoke.sh happy-e2e +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "[lima-smoke] expected macOS (Darwin); got: $(uname -s)" >&2 + exit 1 +fi + +if ! command -v limactl >/dev/null 2>&1; then + echo "[lima-smoke] limactl not found. Install Lima first (example: brew install lima)." >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VM_NAME="${1:-happy-e2e}" + +HSTACK_VERSION="${HSTACK_VERSION:-latest}" +HSTACK_SMOKE_KEEP="${HSTACK_SMOKE_KEEP:-0}" +HSTACK_PROVISION_PROFILE="${HSTACK_PROVISION_PROFILE:-happier}" + +pick_raw_base() { + if [[ -n "${HSTACK_RAW_BASE:-}" ]]; then + echo "${HSTACK_RAW_BASE}" + return 0 + fi + local candidates=( + "https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack" + ) + local c + for c in "${candidates[@]}"; do + if curl -fsSL "${c}/scripts/provision/linux-ubuntu-provision.sh" -o /dev/null >/dev/null 2>&1; then + echo "$c" + return 0 + fi + done + return 1 +} + +HSTACK_RAW_BASE="$(pick_raw_base || true)" +if [[ -z "${HSTACK_RAW_BASE}" ]]; then + echo "[lima-smoke] failed to auto-detect raw GitHub base URL for scripts." >&2 + echo "[lima-smoke] Fix: set HSTACK_RAW_BASE=https://raw.githubusercontent.com/<org>/<repo>/<ref>/apps/stack" >&2 + exit 1 +fi + +echo "[lima-smoke] vm: ${VM_NAME}" +echo "[lima-smoke] @happier-dev/stack: ${HSTACK_VERSION}" +echo "[lima-smoke] provision profile: ${HSTACK_PROVISION_PROFILE}" +echo "[lima-smoke] raw base: ${HSTACK_RAW_BASE}" + +echo "[lima-smoke] ensure VM exists + port forwarding..." +"${SCRIPT_DIR}/macos-lima-vm.sh" "${VM_NAME}" + +echo "[lima-smoke] running provisioning + smoke inside VM..." +limactl shell "${VM_NAME}" -- bash -lc " + set -euo pipefail + echo '[vm] downloading provision + smoke scripts...' + curl -fsSL '${HSTACK_RAW_BASE}/scripts/provision/linux-ubuntu-provision.sh' -o /tmp/linux-ubuntu-provision.sh + chmod +x /tmp/linux-ubuntu-provision.sh + /tmp/linux-ubuntu-provision.sh --profile='${HSTACK_PROVISION_PROFILE}' + + curl -fsSL '${HSTACK_RAW_BASE}/scripts/provision/linux-ubuntu-hstack-smoke.sh' -o /tmp/linux-ubuntu-hstack-smoke.sh + chmod +x /tmp/linux-ubuntu-hstack-smoke.sh + + export HSTACK_VERSION='${HSTACK_VERSION}' + export HSTACK_SMOKE_KEEP='${HSTACK_SMOKE_KEEP}' + /tmp/linux-ubuntu-hstack-smoke.sh +" + +echo "" +echo "[lima-smoke] done." + diff --git a/apps/stack/scripts/provision/macos-lima-vm.sh b/apps/stack/scripts/provision/macos-lima-vm.sh new file mode 100755 index 000000000..8dc4eff45 --- /dev/null +++ b/apps/stack/scripts/provision/macos-lima-vm.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Create/configure a Lima VM for running Happier stack flows in a clean Linux environment, +# while keeping Expo web URLs openable on the macOS host via localhost port forwarding +# (required for WebCrypto/secure-context APIs). +# +# Usage (macOS host): +# ./scripts/provision/macos-lima-vm.sh [vm-name] +# +# Env overrides: +# - LIMA_TEMPLATE (default: ubuntu-24.04) +# - LIMA_MEMORY (default: 8GiB) +# - LIMA_VM_TYPE (optional: vz|qemu|krunkit) +# - HAPPIER_LIMA_STACK_PORT_RANGE (default: 13000-13999) +# - HAPPIER_LIMA_EXPO_PORT_RANGE (default: 18000-19099) + +usage() { + cat <<'EOF' +Usage: + ./scripts/provision/macos-lima-vm.sh [vm-name] + +Examples: + ./scripts/provision/macos-lima-vm.sh + ./scripts/provision/macos-lima-vm.sh happy-e2e + +Notes: +- Run on macOS (Darwin) host. +- Configures localhost port forwarding so you can open http://localhost / http://*.localhost + in your macOS browser (required for WebCrypto APIs used by Expo web). +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "[lima] expected macOS (Darwin); got: $(uname -s)" >&2 + exit 1 +fi + +if ! command -v limactl >/dev/null 2>&1; then + echo "[lima] limactl not found. Install Lima first (example: brew install lima)." >&2 + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "[lima] python3 not found. Install Python 3 to edit Lima YAML." >&2 + exit 1 +fi + +VM_NAME="${1:-happy-test}" +TEMPLATE="${LIMA_TEMPLATE:-ubuntu-24.04}" +LIMA_MEMORY="${LIMA_MEMORY:-8GiB}" +LIMA_VM_TYPE="${LIMA_VM_TYPE:-}" # optional: vz|qemu|krunkit (see: limactl create --list-drivers) + +STACK_PORT_RANGE="${HAPPIER_LIMA_STACK_PORT_RANGE:-13000-13999}" +EXPO_PORT_RANGE="${HAPPIER_LIMA_EXPO_PORT_RANGE:-18000-19099}" + +TEMPLATE_LOCATOR="${TEMPLATE}" +if [[ "${TEMPLATE_LOCATOR}" == template://* ]]; then + TEMPLATE_LOCATOR="template:${TEMPLATE_LOCATOR#template://}" +elif [[ "${TEMPLATE_LOCATOR}" != template:* ]]; then + TEMPLATE_LOCATOR="template:${TEMPLATE_LOCATOR}" +fi + +LIMA_HOME_DIR="${LIMA_HOME:-${HOME}/.lima}" +export LIMA_HOME="${LIMA_HOME_DIR}" +LIMA_DIR="${LIMA_HOME_DIR}/${VM_NAME}" +LIMA_YAML="${LIMA_DIR}/lima.yaml" + +echo "[lima] vm: ${VM_NAME}" +echo "[lima] template: ${TEMPLATE}" +echo "[lima] memory: ${LIMA_MEMORY} (override with LIMA_MEMORY=...)" +echo "[lima] stack ports: ${STACK_PORT_RANGE}" +echo "[lima] expo ports: ${EXPO_PORT_RANGE}" +echo "[lima] LIMA_HOME: ${LIMA_HOME_DIR}" +if [[ -n "${LIMA_VM_TYPE}" ]]; then + echo "[lima] vmType: ${LIMA_VM_TYPE}" +fi + +if [[ ! -f "${LIMA_YAML}" ]]; then + echo "[lima] creating VM..." + if [[ "${LIMA_VM_TYPE}" == "qemu" ]]; then + if ! command -v qemu-system-aarch64 >/dev/null 2>&1; then + echo "[lima] qemu vmType requested but qemu-system-aarch64 is missing." >&2 + echo "[lima] Fix: brew install qemu" >&2 + exit 1 + fi + fi + + create_args=(create --name "${VM_NAME}" --tty=false) + if [[ -n "${LIMA_VM_TYPE}" ]]; then + create_args+=(--vm-type "${LIMA_VM_TYPE}") + fi + create_args+=("${TEMPLATE_LOCATOR}") + limactl "${create_args[@]}" +fi + +if [[ ! -f "${LIMA_YAML}" ]]; then + echo "[lima] expected instance config at: ${LIMA_YAML}" >&2 + exit 1 +fi + +echo "[lima] stopping VM (if running)..." +limactl stop "${VM_NAME}" >/dev/null 2>&1 || true + +echo "[lima] configuring port forwarding (localhost)..." +cp -a "${LIMA_YAML}" "${LIMA_YAML}.bak.$(date +%Y%m%d-%H%M%S)" + +VM_NAME="${VM_NAME}" \ +LIMA_YAML="${LIMA_YAML}" \ +LIMA_MEMORY="${LIMA_MEMORY}" \ +STACK_PORT_RANGE="${STACK_PORT_RANGE}" \ +EXPO_PORT_RANGE="${EXPO_PORT_RANGE}" \ +python3 - <<'PY' +import os, re +from pathlib import Path + +vm_name = os.environ["VM_NAME"] +path = Path(os.environ["LIMA_YAML"]) +memory = os.environ.get("LIMA_MEMORY", "8GiB") + +def parse_range(s: str): + m = re.match(r"^\s*(\d+)\s*-\s*(\d+)\s*$", s or "") + if not m: + raise SystemExit(f"invalid port range: {s!r} (expected like 13000-13999)") + a = int(m.group(1)) + b = int(m.group(2)) + if a <= 0 or b <= 0 or b < a: + raise SystemExit(f"invalid port range: {s!r}") + return a, b + +stack_a, stack_b = parse_range(os.environ.get("STACK_PORT_RANGE", "13000-13999")) +expo_a, expo_b = parse_range(os.environ.get("EXPO_PORT_RANGE", "18000-19099")) + +text = path.read_text(encoding="utf-8") + +MEM_MARK_BEGIN = "# --- happier vm sizing (managed) ---" +MEM_MARK_END = "# --- /happier vm sizing ---" +MARK_BEGIN = "# --- happier port forwards (managed) ---" +MARK_END = "# --- /happier port forwards ---" + +entries = [ + f" - guestPortRange: [{stack_a}, {stack_b}]\n hostPortRange: [{stack_a}, {stack_b}]\n", + f" - guestPortRange: [{expo_a}, {expo_b}]\n hostPortRange: [{expo_a}, {expo_b}]\n", +] + +mem_block = ( + f"\n{MEM_MARK_BEGIN}\n" + f'memory: "{memory}"\n' + f"{MEM_MARK_END}\n" +) + +block_as_section = ( + f"\n{MARK_BEGIN}\n" + "portForwards:\n" + + "".join(entries) + + f"{MARK_END}\n" +) + +block_as_list_items = ( + f" {MARK_BEGIN}\n" + + "".join(entries) + + f" {MARK_END}\n" +) + +if MEM_MARK_BEGIN in text and MEM_MARK_END in text: + text = re.sub( + re.escape(MEM_MARK_BEGIN) + r"[\s\S]*?" + re.escape(MEM_MARK_END) + r"\n?", + mem_block.strip("\n") + "\n", + text, + flags=re.MULTILINE, + ) +else: + m = re.search(r"^memory:\s*.*$", text, flags=re.MULTILINE) + if m: + text = re.sub(r"^memory:\s*.*$", f'memory: "{memory}"', text, flags=re.MULTILINE) + else: + text = text.rstrip() + mem_block + +if MARK_BEGIN in text and MARK_END in text: + text = re.sub( + re.escape(MARK_BEGIN) + r"[\s\S]*?" + re.escape(MARK_END) + r"\n?", + block_as_section.strip("\n") + "\n", + text, + flags=re.MULTILINE, + ) +else: + m = re.search(r"^portForwards:\s*$", text, flags=re.MULTILINE) + if m: + insert_at = m.end() + text = text[:insert_at] + "\n" + block_as_list_items + text[insert_at:] + else: + text = text.rstrip() + block_as_section + +path.write_text(text, encoding="utf-8") +print(f"[lima] updated {path} ({vm_name})") +PY + +echo "[lima] starting VM..." +limactl start "${VM_NAME}" + +echo "" +echo "[lima] done." +echo "" +echo "What this script did:" +echo " - ensured the VM exists (${VM_NAME})" +echo " - set VM memory (${LIMA_MEMORY})" +echo " - configured localhost port forwarding:" +echo " - stack/server: ${STACK_PORT_RANGE}" +echo " - Expo (web): ${EXPO_PORT_RANGE}" +echo "" +echo "Next step (enter the VM):" +printf " limactl shell %s\n" "${VM_NAME}" +echo "" +cat <<'EOF' +Inside the VM: choose a provisioning profile (dependencies only) + +Profile `happier` (recommended for most manual testing): + - Installs: build tools + git + Node + Corepack/Yarn. + - Use when: you want to run `npx ... hstack ...` and iterate quickly without relying on the official installer. + - Run: + curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-provision.sh -o /tmp/linux-ubuntu-provision.sh \ + && chmod +x /tmp/linux-ubuntu-provision.sh \ + && /tmp/linux-ubuntu-provision.sh --profile=happier + +Profile `installer` (clean-machine installer validation): + - Installs: minimal tooling only (curl/ca-certs/etc). DOES NOT install Node/Yarn. + - Use when: you want to validate the “fresh box” experience via the official installer. + - Run: + curl -fsSL https://raw.githubusercontent.com/happier-dev/happier/main/apps/stack/scripts/provision/linux-ubuntu-provision.sh -o /tmp/linux-ubuntu-provision.sh \ + && chmod +x /tmp/linux-ubuntu-provision.sh \ + && /tmp/linux-ubuntu-provision.sh --profile=installer + # Then run the official installer: + curl -fsSL https://happier.dev/install | bash + +Profile `bare` (no changes at all): + - Installs: nothing. + - Use when: you want a totally untouched VM and will install everything yourself (or validate your own bootstrap flow). + - Run: + /tmp/linux-ubuntu-provision.sh --profile=bare + +After provisioning (common next commands): + - Dev monorepo wizard (clones + bootstraps a workspace): + npx --yes -p @happier-dev/stack@latest hstack setup --profile=dev --bind=loopback + - Selfhost (lighter; good for quickly validating server/UI boot without auth): + npx --yes -p @happier-dev/stack@latest hstack setup --profile=selfhost --no-auth --no-tailscale --no-autostart --no-menubar --bind=loopback + +Tips: + - Open printed URLs on your macOS host via http://localhost:<port> or http://*.localhost:<port>. + - For `npx --yes -p @happier-dev/stack hstack tools review-pr ...` inside the VM, pass `--vm-ports` so stack ports land in the forwarded ranges. + - Override guest provision versions (profile=happier): + HAPPIER_PROVISION_NODE_MAJOR=24 HAPPIER_PROVISION_YARN_VERSION=1.22.22 /tmp/linux-ubuntu-provision.sh --profile=happier +EOF diff --git a/apps/stack/scripts/provision/macos-lima-vm.test.mjs b/apps/stack/scripts/provision/macos-lima-vm.test.mjs new file mode 100644 index 000000000..0cb10f4c1 --- /dev/null +++ b/apps/stack/scripts/provision/macos-lima-vm.test.mjs @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, chmod, readFile, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +async function fileExists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +test('macos lima VM script prints tips without executing backticks', async () => { + const root = await mkdtemp(join(tmpdir(), 'hstack-macos-lima-vm-test-')); + const binDir = join(root, 'bin'); + const homeDir = join(root, 'home'); + const logDir = join(root, 'logs'); + await writeFile(join(root, '.keep'), 'ok\n', 'utf-8'); + await Promise.all([ + writeFile(join(root, 'README.txt'), 'tmp\n', 'utf-8'), + ]); + + const { mkdir } = await import('node:fs/promises'); + await mkdir(binDir, { recursive: true }); + await mkdir(homeDir, { recursive: true }); + await mkdir(logDir, { recursive: true }); + + const npxLog = join(logDir, 'npx.log'); + const limactlLog = join(logDir, 'limactl.log'); + + const unamePath = join(binDir, 'uname'); + await writeFile( + unamePath, + ['#!/usr/bin/env bash', 'echo Darwin'].join('\n') + '\n', + 'utf-8' + ); + await chmod(unamePath, 0o755); + + const npxPath = join(binDir, 'npx'); + await writeFile( + npxPath, + [ + '#!/usr/bin/env bash', + `echo "npx $*" >> ${JSON.stringify(npxLog)}`, + 'exit 77', + ].join('\n') + '\n', + 'utf-8' + ); + await chmod(npxPath, 0o755); + + const pythonPath = join(binDir, 'python3'); + await writeFile( + pythonPath, + [ + '#!/usr/bin/env bash', + 'cat >/dev/null || true', + 'exit 0', + ].join('\n') + '\n', + 'utf-8' + ); + await chmod(pythonPath, 0o755); + + const limactlPath = join(binDir, 'limactl'); + await writeFile( + limactlPath, + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + `echo "limactl $*" >> ${JSON.stringify(limactlLog)}`, + 'LIMA_HOME_DIR="${LIMA_HOME:-${HOME}/.lima}"', + 'cmd="${1:-}"', + 'shift || true', + 'if [[ "$cmd" == "create" ]]; then', + ' name=""', + ' while [[ $# -gt 0 ]]; do', + ' if [[ "$1" == "--name" ]]; then', + ' name="$2"', + ' shift 2', + ' continue', + ' fi', + ' shift || true', + ' done', + ' if [[ -z "$name" ]]; then', + ' echo "missing --name" >&2', + ' exit 2', + ' fi', + ' dir="${LIMA_HOME_DIR}/${name}"', + ' mkdir -p "$dir"', + ' if [[ ! -f "$dir/lima.yaml" ]]; then', + ' printf "%s\\n" "memory: \\"4GiB\\"" > "$dir/lima.yaml"', + ' fi', + ' exit 0', + 'fi', + 'exit 0', + ].join('\n') + '\n', + 'utf-8' + ); + await chmod(limactlPath, 0o755); + + const scriptPath = join(__dirname, 'macos-lima-vm.sh'); + + const env = { + ...process.env, + HOME: homeDir, + LIMA_HOME: join(homeDir, '.lima'), + PATH: `${binDir}:${process.env.PATH ?? ''}`, + }; + + const res = spawnSync('bash', [scriptPath, 'happy-test'], { + env, + cwd: root, + encoding: 'utf-8', + }); + + assert.equal(res.status, 0, `expected exit 0\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + + assert.match(res.stdout, /`--vm-ports`/, 'expected backticks to be printed literally'); + assert.match(res.stdout, /linux-ubuntu-provision\.sh/, 'expected guest provision script to be referenced'); + assert.match(res.stdout, /--profile=happier/, 'expected profile=happier to be documented'); + assert.match(res.stdout, /--profile=installer/, 'expected profile=installer to be documented'); + assert.match(res.stdout, /--profile=bare/, 'expected profile=bare to be documented'); + assert.equal(await fileExists(npxLog), false, 'expected script not to execute npx'); + + const limactlOut = await readFile(limactlLog, 'utf-8'); + assert.match(limactlOut, /limactl (create|stop|start)/, 'expected script to invoke limactl'); +}); diff --git a/apps/stack/scripts/repo_cli_activate.mjs b/apps/stack/scripts/repo_cli_activate.mjs new file mode 100644 index 000000000..92da7abc6 --- /dev/null +++ b/apps/stack/scripts/repo_cli_activate.mjs @@ -0,0 +1,41 @@ +import './utils/env/env.mjs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Repo convenience: configure global hstack/happier shims to run from THIS monorepo checkout. + * + * Why: + * - Developers often have a stable runtime install, but want the CLI they run from any terminal + * to come from their local clone instead (to test changes). + * + * What it does: + * - Runs: hstack init --cli-root-dir=<repo>/apps/stack --no-runtime --no-bootstrap [--install-path] + * + * Notes: + * - `--install-path` edits shell config; keep it opt-in. + * - Extra args are forwarded to `hstack init` (e.g. --home-dir=/tmp/... in tests). + */ + +function main() { + const scriptsDir = dirname(fileURLToPath(import.meta.url)); // <repo>/apps/stack/scripts + const repoRoot = dirname(dirname(dirname(scriptsDir))); // <repo> + const cliRootDir = join(repoRoot, 'apps', 'stack'); + const hstackBin = join(cliRootDir, 'bin', 'hstack.mjs'); + + const forwarded = process.argv.slice(2); + const argv = [ + 'init', + `--cli-root-dir=${cliRootDir}`, + '--no-runtime', + '--no-bootstrap', + ...forwarded, + ]; + + const res = spawnSync(process.execPath, [hstackBin, ...argv], { stdio: 'inherit', env: process.env }); + process.exit(res.status ?? 1); +} + +main(); + diff --git a/apps/stack/scripts/repo_cli_activate.test.mjs b/apps/stack/scripts/repo_cli_activate.test.mjs new file mode 100644 index 000000000..a09de5d25 --- /dev/null +++ b/apps/stack/scripts/repo_cli_activate.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function runNode(args, { cwd, env }) { + return new Promise((resolvePromise, reject) => { + const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d) => (stdout += String(d))); + proc.stderr.on('data', (d) => (stderr += String(d))); + proc.on('error', reject); + proc.on('exit', (code, signal) => resolvePromise({ code: code ?? (signal ? 1 : 0), signal, stdout, stderr })); + }); +} + +test('repo cli activate configures init with cli-root-dir pointing at this checkout', async () => { + const scriptsDir = dirname(fileURLToPath(import.meta.url)); + const packageRoot = dirname(scriptsDir); // apps/stack + const repoRoot = dirname(dirname(packageRoot)); // repo root + + const homeDir = mkdtempSync(join(tmpdir(), 'happier-repo-cli-activate-home-')); + const canonicalHomeDir = mkdtempSync(join(tmpdir(), 'happier-repo-cli-activate-canonical-')); + try { + const res = await runNode( + [ + join(packageRoot, 'scripts', 'repo_cli_activate.mjs'), + `--home-dir=${homeDir}`, + `--canonical-home-dir=${canonicalHomeDir}`, + '--no-runtime', // redundant; ensure we don't accidentally install runtime during the test + '--no-bootstrap', + ], + { cwd: repoRoot, env: process.env } + ); + assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + + const homeEnvPath = join(homeDir, '.env'); + const homeEnv = readFileSync(homeEnvPath, 'utf-8'); + const expectedCliRootDir = resolve(join(repoRoot, 'apps', 'stack')); + assert.match( + homeEnv, + new RegExp(`^HAPPIER_STACK_CLI_ROOT_DIR=${expectedCliRootDir.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}$`, 'm'), + `expected init to persist cli root dir override in ${homeEnvPath}\n${homeEnv}` + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + rmSync(canonicalHomeDir, { recursive: true, force: true }); + } +}); + diff --git a/apps/stack/scripts/repo_local.mjs b/apps/stack/scripts/repo_local.mjs index 0d8e67070..e6812af45 100644 --- a/apps/stack/scripts/repo_local.mjs +++ b/apps/stack/scripts/repo_local.mjs @@ -7,6 +7,31 @@ import { fileURLToPath } from 'node:url'; import { scrubHappierStackEnv, STACK_WRAPPER_PRESERVE_KEYS } from './utils/env/scrub_env.mjs'; import { applyStackActiveServerScopeEnv } from './utils/auth/stable_scope_id.mjs'; +import { ensureDepsInstalled } from './utils/proc/pm.mjs'; +import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs'; +import { parseEnvToObject } from './utils/env/dotenv.mjs'; +import { resolveLocalServerPortForStack } from './utils/server/resolve_stack_server_port.mjs'; + +function shouldAutoInstallDepsForRepoLocalCommand(cmd) { + const c = String(cmd ?? '').trim(); + if (!c) return false; + if (c === 'help' || c === '--help' || c === '-h') return false; + if (c === 'where') return false; + if (c === 'stop') return false; + return true; +} + +async function maybeAutoInstallRepoDeps({ repoRoot, cmd, env, autoInstallOverride = '', preflightRootOverride = '' }) { + const autoInstallRaw = String(autoInstallOverride ?? '').trim(); + const autoInstall = autoInstallRaw ? autoInstallRaw !== '0' : true; + if (!autoInstall) return; + if (!shouldAutoInstallDepsForRepoLocalCommand(cmd)) return; + + // Test hook: allow validating auto-install behavior without mutating the real repo checkout. + const preflightRoot = String(preflightRootOverride ?? '').trim() || repoRoot; + + await ensureDepsInstalled(preflightRoot, 'happier-monorepo', { quiet: false, env }); +} function usage() { return [ @@ -41,6 +66,19 @@ function stringifyEnvFile(env) { return lines.join('\n') + '\n'; } +function coercePositiveInt(v) { + const n = Number(String(v ?? '').trim()); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : null; +} + +function isPortWithinRange(port, base, range) { + const p = coercePositiveInt(port); + const b = coercePositiveInt(base); + const r = coercePositiveInt(range); + if (!p || !b || !r) return false; + return p >= b && p < b + r; +} + function sanitizeStackNameToken(s) { const raw = String(s ?? '').trim().toLowerCase(); const cleaned = raw.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); @@ -87,33 +125,43 @@ function readTextFile(path) { } } -function writeTextFileBestEffort(path, contents) { +function readEnvFileObject(path) { + const raw = readTextFile(path); + if (!raw.trim()) return {}; try { - if (!path) return; - writeFileSync(path, String(contents ?? ''), { encoding: 'utf-8' }); + return parseEnvToObject(raw); } catch { - // ignore + return {}; } } -function writeEnvFileBestEffort(path, env) { +function writeTextFileBestEffort(path, contents) { try { if (!path) return; - const next = stringifyEnvFile(env); - let prev = ''; - try { - prev = existsSync(path) ? readFileSync(path, 'utf-8').toString() : ''; - } catch { - prev = ''; - } - if (prev !== next) { - writeFileSync(path, next, { encoding: 'utf-8' }); - } + writeFileSync(path, String(contents ?? ''), { encoding: 'utf-8' }); } catch { // ignore } } +async function syncRepoLocalEnvFile({ envPath, managedEnv = {}, pruneKeys = [] } = {}) { + const target = String(envPath ?? '').trim(); + if (!target) return; + + const updates = Object.entries(managedEnv ?? {}) + .map(([k, v]) => ({ key: String(k ?? '').trim(), value: v == null ? '' : String(v) })) + .filter((u) => u.key && u.value.trim() !== ''); + + // Preserve user keys: only upsert a small managed keyset, and prune specific stale managed keys. + if (updates.length) { + await ensureEnvFileUpdated({ envPath: target, updates }); + } + const removeKeys = Array.from(new Set((pruneKeys ?? []).map((k) => String(k ?? '').trim()).filter(Boolean))); + if (removeKeys.length) { + await ensureEnvFilePruned({ envPath: target, removeKeys }); + } +} + function stacklessIdForRepo({ repoRoot, stacksStorageRoot, createIfMissing }) { const oldHash = createHash('sha256').update(String(repoRoot)).digest('hex').slice(0, 10); const base = sanitizeStackNameToken(repoRoot.split('/').filter(Boolean).at(-1)); @@ -186,7 +234,23 @@ function readRuntimeServerPort(runtimeStatePath) { } } -function main() { +function readRuntimeExpoPort(runtimeStatePath) { + try { + if (!runtimeStatePath || !existsSync(runtimeStatePath)) return null; + const raw = readFileSync(runtimeStatePath, 'utf-8'); + const parsed = JSON.parse(raw); + const port = Number(parsed?.expo?.port ?? parsed?.expo?.webPort ?? parsed?.expo?.mobilePort); + return Number.isFinite(port) && port > 0 ? port : null; + } catch { + return null; + } +} + +async function main() { + const autoInstallOverride = String(process.env.HAPPIER_STACK_REPO_LOCAL_AUTO_INSTALL ?? '').trim(); + const preflightRootOverride = String(process.env.HAPPIER_STACK_REPO_LOCAL_PREFLIGHT_ROOT ?? '').trim(); + const preflightOnly = String(process.env.HAPPIER_STACK_REPO_LOCAL_PREFLIGHT_ONLY ?? '').trim(); + const argvRaw = process.argv.slice(2); const firstArg = argvRaw[0]; const showWrapperHelp = @@ -236,8 +300,13 @@ function main() { const stacklessBaseDir = join(stacksStorageRoot, stacklessName); const stacklessRuntimePath = join(stacklessBaseDir, 'stack.runtime.json'); const runtimeServerPort = readRuntimeServerPort(stacklessRuntimePath); + const runtimeExpoPort = readRuntimeExpoPort(stacklessRuntimePath); const stacklessEnvPath = join(stacklessBaseDir, 'env'); const stacklessCliHomeDir = join(stacklessBaseDir, 'cli'); + const stacklessLogsDir = join(stacklessBaseDir, 'logs'); + const existingStacklessEnv = readEnvFileObject(stacklessEnvPath); + const existingPinnedServerPort = coercePositiveInt(existingStacklessEnv.HAPPIER_STACK_SERVER_PORT); + const existingPinnedExpoPort = coercePositiveInt(existingStacklessEnv.HAPPIER_STACK_EXPO_DEV_PORT); // Convenience: // `yarn stop` should stop the repo-local stack without requiring users to know its generated name. @@ -246,6 +315,19 @@ function main() { argv = ['stack', 'stop', stacklessName, ...forwarded]; } + // Convenience: + // `yarn mobile:install` should install a Release iOS build for the repo-local stack without requiring users + // to know the generated stack name, and should run the full stack install flow (prebuild, identity, etc). + if (subcommand === 'mobile:install') { + const forwarded = argv.slice(1); + const hasName = forwarded.some((a) => { + const s = String(a ?? '').trim(); + return s === '--name' || s.startsWith('--name=') || s === '--app-name' || s.startsWith('--app-name='); + }); + const defaultNameArg = hasName ? [] : ['--name=Happier (Local)']; + argv = ['stack', 'mobile:install', stacklessName, ...defaultNameArg, ...forwarded]; + } + // Force "repo-local" behavior: // - avoid re-exec into any global install // - avoid pinning to a configured repo dir (infer from invoked cwd) @@ -285,6 +367,8 @@ function main() { // Make stack-owned processes prove ownership (for stop/cleanup) and enable stack commands like `stack auth`. HAPPIER_STACK_ENV_FILE: stacklessEnvPath, HAPPIER_STACK_CLI_HOME_DIR: stacklessCliHomeDir, + // If set, internal spawns can tee output into stack-scoped log files (server.log/expo.log/ui.log). + HAPPIER_STACK_LOG_TEE_DIR: stacklessLogsDir, // Stackless isolation: keep ports away from main/default stack ports by default. HAPPIER_STACK_SERVER_PORT_BASE: (process.env.HAPPIER_STACK_SERVER_PORT_BASE ?? '52005').toString(), HAPPIER_STACK_SERVER_PORT_RANGE: (process.env.HAPPIER_STACK_SERVER_PORT_RANGE ?? '2000').toString(), @@ -292,7 +376,24 @@ function main() { HAPPIER_STACK_EXPO_DEV_PORT_RANGE: (process.env.HAPPIER_STACK_EXPO_DEV_PORT_RANGE ?? '2000').toString(), // Make Expo's Metro use stable (stack-scoped) port strategy. HAPPIER_STACK_EXPO_DEV_PORT_STRATEGY: (process.env.HAPPIER_STACK_EXPO_DEV_PORT_STRATEGY ?? 'stable').toString(), - ...(runtimeServerPort ? { HAPPIER_STACK_SERVER_PORT: String(runtimeServerPort) } : {}), + ...(runtimeServerPort && + !existingPinnedServerPort && + isPortWithinRange( + runtimeServerPort, + process.env.HAPPIER_STACK_SERVER_PORT_BASE ?? '52005', + process.env.HAPPIER_STACK_SERVER_PORT_RANGE ?? '2000' + ) + ? { HAPPIER_STACK_SERVER_PORT: String(runtimeServerPort) } + : {}), + ...(runtimeExpoPort && + !existingPinnedExpoPort && + isPortWithinRange( + runtimeExpoPort, + process.env.HAPPIER_STACK_EXPO_DEV_PORT_BASE ?? '18081', + process.env.HAPPIER_STACK_EXPO_DEV_PORT_RANGE ?? '2000' + ) + ? { HAPPIER_STACK_EXPO_DEV_PORT: String(runtimeExpoPort) } + : {}), }), HAPPIER_STACK_INVOKED_CWD: invokedCwd, }; @@ -307,14 +408,54 @@ function main() { try { mkdirSync(stacklessBaseDir, { recursive: true }); mkdirSync(stacklessCliHomeDir, { recursive: true }); + mkdirSync(stacklessLogsDir, { recursive: true }); } catch { // ignore (best-effort) } - // Treat the repo-local stack as managed by the wrapper: keep a minimal env file in sync. - // This makes `hstack stack auth <name> ...` and `hstack stack stop <name>` work without requiring manual `stack new`. - const serverComponent = (effectiveEnv.HAPPIER_STACK_SERVER_COMPONENT ?? 'happier-server-light').toString().trim() || 'happier-server-light'; - writeEnvFileBestEffort(stacklessEnvPath, { + const serverComponent = (effectiveEnv.HAPPIER_STACK_SERVER_COMPONENT ?? 'happier-server-light').toString().trim() || 'happier-server-light'; + const serverBase = effectiveEnv.HAPPIER_STACK_SERVER_PORT_BASE; + const serverRange = effectiveEnv.HAPPIER_STACK_SERVER_PORT_RANGE; + const expoBase = effectiveEnv.HAPPIER_STACK_EXPO_DEV_PORT_BASE; + const expoRange = effectiveEnv.HAPPIER_STACK_EXPO_DEV_PORT_RANGE; + + // Persist a stable pinned server port early so repo-local "global-ish" commands like + // `yarn tailscale enable` and `yarn service install` can resolve the correct internal URL + // even before the first `yarn dev/start` run creates stack.runtime.json. + let persistedServerPort = null; + if (!existingPinnedServerPort) { + if (runtimeServerPort && isPortWithinRange(runtimeServerPort, serverBase, serverRange)) { + persistedServerPort = runtimeServerPort; + } else { + persistedServerPort = await resolveLocalServerPortForStack({ + env: { + ...effectiveEnv, + HAPPIER_STACK_SERVER_PORT_BASE: (effectiveEnv.HAPPIER_STACK_SERVER_PORT_BASE ?? '52005').toString(), + HAPPIER_STACK_SERVER_PORT_RANGE: (effectiveEnv.HAPPIER_STACK_SERVER_PORT_RANGE ?? '2000').toString(), + }, + stackMode: true, + stackName: stacklessName, + runtimeStatePath: stacklessRuntimePath, + defaultPort: 3005, + }).catch(() => null); + } + } + + // Auto-heal: + // If a stale pinned port exists in the stackless env file but it doesn't match the configured stable range, + // prune it so dev/start can pick a stable high port again. + const pruneKeys = []; + if ( + existingPinnedServerPort && + existingPinnedServerPort < 5000 && + !isPortWithinRange(existingPinnedServerPort, serverBase, serverRange) + ) { + pruneKeys.push('HAPPIER_STACK_SERVER_PORT'); + } + + // Treat the repo-local stack as managed by the wrapper: keep a small set of stack-owned keys in sync, + // but preserve any user-defined keys they set via `hstack env` / `yarn env`. + const managedEnv = { HAPPIER_STACK_STACK: stacklessName, HAPPIER_STACK_REPO_DIR: repoRoot, HAPPIER_STACK_SERVER_COMPONENT: serverComponent, @@ -322,12 +463,22 @@ function main() { HAPPIER_STACK_SERVER_PORT_BASE: effectiveEnv.HAPPIER_STACK_SERVER_PORT_BASE, HAPPIER_STACK_SERVER_PORT_RANGE: effectiveEnv.HAPPIER_STACK_SERVER_PORT_RANGE, HAPPIER_STACK_EXPO_DEV_PORT_BASE: effectiveEnv.HAPPIER_STACK_EXPO_DEV_PORT_BASE, - HAPPIER_STACK_EXPO_DEV_PORT_RANGE: effectiveEnv.HAPPIER_STACK_EXPO_DEV_PORT_RANGE, - HAPPIER_STACK_EXPO_DEV_PORT_STRATEGY: effectiveEnv.HAPPIER_STACK_EXPO_DEV_PORT_STRATEGY, - ...(runtimeServerPort ? { HAPPIER_STACK_SERVER_PORT: String(runtimeServerPort) } : {}), - // Keep the stable active server id explicit so daemons/CLI always scope state/credentials per stack. - ...(effectiveEnv.HAPPIER_ACTIVE_SERVER_ID ? { HAPPIER_ACTIVE_SERVER_ID: effectiveEnv.HAPPIER_ACTIVE_SERVER_ID } : {}), - }); + HAPPIER_STACK_EXPO_DEV_PORT_RANGE: effectiveEnv.HAPPIER_STACK_EXPO_DEV_PORT_RANGE, + HAPPIER_STACK_EXPO_DEV_PORT_STRATEGY: effectiveEnv.HAPPIER_STACK_EXPO_DEV_PORT_STRATEGY, + // Keep the stable active server id explicit so daemons/CLI always scope state/credentials per stack. + ...(effectiveEnv.HAPPIER_ACTIVE_SERVER_ID ? { HAPPIER_ACTIVE_SERVER_ID: effectiveEnv.HAPPIER_ACTIVE_SERVER_ID } : {}), + ...(persistedServerPort && + !existingPinnedServerPort && + isPortWithinRange(persistedServerPort, serverBase, serverRange) + ? { HAPPIER_STACK_SERVER_PORT: String(persistedServerPort) } + : {}), + ...(runtimeExpoPort && + !existingPinnedExpoPort && + isPortWithinRange(runtimeExpoPort, expoBase, expoRange) + ? { HAPPIER_STACK_EXPO_DEV_PORT: String(runtimeExpoPort) } + : {}), + }; + await syncRepoLocalEnvFile({ envPath: stacklessEnvPath, managedEnv, pruneKeys }); } const cmd = process.execPath; @@ -350,6 +501,7 @@ function main() { HAPPIER_STACK_SERVER_PORT: effectiveEnv.HAPPIER_STACK_SERVER_PORT, HAPPIER_STACK_ENV_FILE: effectiveEnv.HAPPIER_STACK_ENV_FILE, HAPPIER_STACK_CLI_HOME_DIR: effectiveEnv.HAPPIER_STACK_CLI_HOME_DIR, + HAPPIER_STACK_LOG_TEE_DIR: effectiveEnv.HAPPIER_STACK_LOG_TEE_DIR, HAPPIER_ACTIVE_SERVER_ID: effectiveEnv.HAPPIER_ACTIVE_SERVER_ID, HAPPIER_STACK_INVOKED_CWD: effectiveEnv.HAPPIER_STACK_INVOKED_CWD, }, @@ -361,8 +513,29 @@ function main() { return; } + try { + await maybeAutoInstallRepoDeps({ + repoRoot, + cmd: subcommand, + env: effectiveEnv, + autoInstallOverride, + preflightRootOverride, + }); + } catch (e) { + process.stderr.write(`[repo-local] failed to install repo deps\n${String(e?.stack ?? e)}\n`); + process.stderr.write('\nFix:\n corepack enable\n yarn install\n'); + process.exit(1); + } + + if (preflightOnly === '1') { + process.exit(0); + } + const res = spawnSync(cmd, args, { cwd, env: effectiveEnv, stdio: 'inherit' }); process.exit(res.status ?? 1); } -main(); +main().catch((e) => { + process.stderr.write(`[repo-local] ${String(e?.stack ?? e)}\n`); + process.exit(1); +}); diff --git a/apps/stack/scripts/repo_local_wrapper.test.mjs b/apps/stack/scripts/repo_local_wrapper.test.mjs index d9dfc317c..710a1eb3f 100644 --- a/apps/stack/scripts/repo_local_wrapper.test.mjs +++ b/apps/stack/scripts/repo_local_wrapper.test.mjs @@ -2,8 +2,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { dirname, join } from 'node:path'; import { spawn } from 'node:child_process'; +import { createServer } from 'node:net'; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; +import { resolveStablePortStart } from './utils/expo/metro_ports.mjs'; + function runNode(args, { cwd, env }) { return new Promise((resolve, reject) => { const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -16,6 +21,38 @@ function runNode(args, { cwd, env }) { }); } +async function listenOnPort(port) { + const srv = createServer(() => {}); + await new Promise((resolve, reject) => { + srv.once('error', reject); + srv.listen({ host: '127.0.0.1', port }, () => resolve()); + }); + return srv; +} + +async function reserveStableStartPort({ stackName, baseCandidates, range }) { + for (const base of baseCandidates) { + const startPort = resolveStablePortStart({ + env: { + HAPPIER_STACK_SERVER_PORT_BASE: String(base), + HAPPIER_STACK_SERVER_PORT_RANGE: String(range), + }, + stackName, + baseKey: 'HAPPIER_STACK_SERVER_PORT_BASE', + rangeKey: 'HAPPIER_STACK_SERVER_PORT_RANGE', + defaultBase: base, + defaultRange: range, + }); + try { + const server = await listenOnPort(startPort); + return { base, range, startPort, server }; + } catch { + // Port in use; try another base. + } + } + throw new Error(`failed to reserve a stable start port (bases=${baseCandidates.join(', ')}, range=${range})`); +} + test('repo-local wrapper dry-run prints hstack invocation with repo-local env', async () => { const scriptsDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = dirname(scriptsDir); // apps/stack @@ -48,6 +85,7 @@ test('repo-local wrapper dry-run prints hstack invocation with repo-local env', assert.ok(String(data.env.HAPPIER_STACK_ENV_FILE ?? '').trim() !== '', 'expected wrapper to set a stack env file path for stack-scoped commands'); assert.ok(String(data.env.HAPPIER_STACK_CLI_HOME_DIR ?? '').trim() !== '', 'expected wrapper to set a stack-scoped CLI home dir'); assert.ok(String(data.env.HAPPIER_ACTIVE_SERVER_ID ?? '').trim() !== '', 'expected wrapper to set a stack-scoped active server id'); + assert.ok(String(data.env.HAPPIER_STACK_LOG_TEE_DIR ?? '').trim() !== '', 'expected wrapper to set a stack-scoped log tee dir'); assert.ok(String(data.env.HAPPIER_STACK_INVOKED_CWD ?? '').trim() !== ''); }); @@ -153,3 +191,264 @@ test('repo-local wrapper maps `stop` to stack stop for the repo-local stack', as assert.equal(data.args[2], 'stop'); assert.ok(String(data.args[3] ?? '').trim() !== ''); }); + +test('repo-local wrapper maps `mobile:install` to stack mobile:install for the repo-local stack', async () => { + const scriptsDir = dirname(fileURLToPath(import.meta.url)); + const packageRoot = dirname(scriptsDir); // apps/stack + const repoRoot = dirname(dirname(packageRoot)); // repo root + + const res = await runNode( + [join(packageRoot, 'scripts', 'repo_local.mjs'), 'mobile:install', '--dry-run'], + { + cwd: repoRoot, + env: process.env, + } + ); + assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + + const data = JSON.parse(res.stdout); + assert.equal(data.ok, true); + assert.equal(data.args[1], 'stack'); + assert.equal(data.args[2], 'mobile:install'); + assert.ok(String(data.args[3] ?? '').trim().startsWith('repo-'), `expected repo-local stack name, got: ${data.args[3]}`); + + // Convenience: default name should be user-friendly (the repo-local stack name can be noisy). + assert.ok( + data.args.some((a) => String(a).startsWith('--name=')), + `expected wrapper to set a default --name=... for mobile:install:\n${JSON.stringify(data.args, null, 2)}` + ); +}); + +test('repo-local wrapper auto-installs deps when node_modules are missing', async () => { + const scriptsDir = dirname(fileURLToPath(import.meta.url)); + const packageRoot = dirname(scriptsDir); // apps/stack + const repoRoot = dirname(dirname(packageRoot)); // repo root + + const preflightRoot = mkdtempSync(join(tmpdir(), 'happier-repo-local-preflight-')); + try { + writeFileSync(join(preflightRoot, 'package.json'), JSON.stringify({ name: 'tmp', private: true })); + + const binDir = join(preflightRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const logPath = join(preflightRoot, 'yarn.log'); + const yarnBin = join(binDir, 'yarn'); + writeFileSync( + yarnBin, + [ + '#!/usr/bin/env node', + "import { appendFileSync, mkdirSync } from 'node:fs';", + "import { dirname, join } from 'node:path';", + 'const logPath = process.env.YARN_LOG;', + "appendFileSync(logPath, process.argv.slice(2).join(' ') + '\\n');", + "if (process.argv.includes('install')) {", + " const nodeModules = join(process.cwd(), 'node_modules');", + " mkdirSync(nodeModules, { recursive: true });", + '}', + 'process.exit(0);', + ].join('\n') + '\n', + ); + chmodSync(yarnBin, 0o755); + + const res = await runNode( + [join(packageRoot, 'scripts', 'repo_local.mjs'), 'dev'], + { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + YARN_LOG: logPath, + HAPPIER_STACK_REPO_LOCAL_PREFLIGHT_ROOT: preflightRoot, + HAPPIER_STACK_REPO_LOCAL_PREFLIGHT_ONLY: '1', + }, + } + ); + + assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + const log = readFileSync(logPath, 'utf-8'); + assert.match(log, /\binstall\b/); + } finally { + rmSync(preflightRoot, { recursive: true, force: true }); + } +}); + +test('repo-local wrapper preserves user-defined env keys while managing stack-owned keys', async () => { + const scriptsDir = dirname(fileURLToPath(import.meta.url)); + const packageRoot = dirname(scriptsDir); // apps/stack + const repoRoot = dirname(dirname(packageRoot)); // repo root + + const stacksRoot = mkdtempSync(join(tmpdir(), 'happier-repo-local-stacks-')); + try { + // First: compute the repo-local stack env file path without mutating the real repo checkout. + // (We use --dry-run so the wrapper doesn't create/update any local state.) + const dry = await runNode( + [join(packageRoot, 'scripts', 'repo_local.mjs'), 'dev', '--dry-run'], + { + cwd: repoRoot, + env: { + ...process.env, + HAPPIER_STACK_STORAGE_DIR: stacksRoot, + }, + } + ); + assert.equal(dry.code, 0, `expected exit 0, got ${dry.code}\nstdout:\n${dry.stdout}\nstderr:\n${dry.stderr}`); + const dryData = JSON.parse(dry.stdout); + const envPath = String(dryData?.env?.HAPPIER_STACK_ENV_FILE ?? '').trim(); + assert.ok(envPath, 'expected dry-run to include HAPPIER_STACK_ENV_FILE'); + + // Seed env file with a user-defined key and pinned ports. + mkdirSync(dirname(envPath), { recursive: true }); + writeFileSync( + envPath, + ['CUSTOM_KEY=1', 'HAPPIER_STACK_SERVER_PORT=9999', 'HAPPIER_STACK_EXPO_DEV_PORT=19999', ''].join('\n') + ); + + // Next: run a command that exercises the wrapper's env-file sync logic but does not spawn hstack. + const res = await runNode( + [join(packageRoot, 'scripts', 'repo_local.mjs'), 'stop'], + { + cwd: repoRoot, + env: { + ...process.env, + HAPPIER_STACK_STORAGE_DIR: stacksRoot, + HAPPIER_STACK_REPO_LOCAL_PREFLIGHT_ONLY: '1', + }, + } + ); + assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + + const updated = readFileSync(envPath, 'utf-8'); + assert.match(updated, /\bCUSTOM_KEY=1\b/, `expected user key to be preserved:\n${updated}`); + assert.match(updated, /\bHAPPIER_STACK_SERVER_PORT=9999\b/, `expected pinned port to be preserved:\n${updated}`); + assert.match(updated, /\bHAPPIER_STACK_EXPO_DEV_PORT=19999\b/, `expected pinned expo port to be preserved:\n${updated}`); + } finally { + rmSync(stacksRoot, { recursive: true, force: true }); + } +}); + +test('repo-local wrapper prunes pinned server port when it falls outside the configured stackless port range', async () => { + const scriptsDir = dirname(fileURLToPath(import.meta.url)); + const packageRoot = dirname(scriptsDir); // apps/stack + const repoRoot = dirname(dirname(packageRoot)); // repo root + + const stacksRoot = mkdtempSync(join(tmpdir(), 'happier-repo-local-stacks-')); + try { + const dry = await runNode( + [join(packageRoot, 'scripts', 'repo_local.mjs'), 'dev', '--dry-run'], + { + cwd: repoRoot, + env: { + ...process.env, + HAPPIER_STACK_STORAGE_DIR: stacksRoot, + }, + } + ); + assert.equal(dry.code, 0, `expected exit 0, got ${dry.code}\nstdout:\n${dry.stdout}\nstderr:\n${dry.stderr}`); + const dryData = JSON.parse(dry.stdout); + const envPath = String(dryData?.env?.HAPPIER_STACK_ENV_FILE ?? '').trim(); + assert.ok(envPath, 'expected dry-run to include HAPPIER_STACK_ENV_FILE'); + + // Seed env with a stale pinned port in the legacy range. Stackless is expected to use the + // high stable range (default base/range managed by the wrapper). + mkdirSync(dirname(envPath), { recursive: true }); + writeFileSync( + envPath, + [ + 'CUSTOM_KEY=1', + 'HAPPIER_STACK_SERVER_PORT_BASE=52005', + 'HAPPIER_STACK_SERVER_PORT_RANGE=2000', + 'HAPPIER_STACK_SERVER_PORT=3009', + '', + ].join('\n') + ); + + const res = await runNode( + [join(packageRoot, 'scripts', 'repo_local.mjs'), 'stop'], + { + cwd: repoRoot, + env: { + ...process.env, + HAPPIER_STACK_STORAGE_DIR: stacksRoot, + HAPPIER_STACK_REPO_LOCAL_PREFLIGHT_ONLY: '1', + }, + } + ); + assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + + const updated = readFileSync(envPath, 'utf-8'); + assert.match(updated, /\bCUSTOM_KEY=1\b/, `expected user key to be preserved:\n${updated}`); + assert.match(updated, /\bHAPPIER_STACK_SERVER_PORT_BASE=52005\b/, `expected base to be preserved:\n${updated}`); + assert.match(updated, /\bHAPPIER_STACK_SERVER_PORT_RANGE=2000\b/, `expected range to be preserved:\n${updated}`); + assert.doesNotMatch(updated, /\bHAPPIER_STACK_SERVER_PORT=3009\b/, `expected stale pinned port to be pruned:\n${updated}`); + } finally { + rmSync(stacksRoot, { recursive: true, force: true }); + } +}); + +test('repo-local wrapper persists a stable pinned server port when none is present (service/tailscale pre-start)', async () => { + const scriptsDir = dirname(fileURLToPath(import.meta.url)); + const packageRoot = dirname(scriptsDir); // apps/stack + const repoRoot = dirname(dirname(packageRoot)); // repo root + + const stacksRoot = mkdtempSync(join(tmpdir(), 'happier-repo-local-stacks-')); + try { + const dry = await runNode( + [join(packageRoot, 'scripts', 'repo_local.mjs'), 'dev', '--dry-run'], + { + cwd: repoRoot, + env: { + ...process.env, + HAPPIER_STACK_STORAGE_DIR: stacksRoot, + }, + } + ); + assert.equal(dry.code, 0, `expected exit 0, got ${dry.code}\nstdout:\n${dry.stdout}\nstderr:\n${dry.stderr}`); + const dryData = JSON.parse(dry.stdout); + const envPath = String(dryData?.env?.HAPPIER_STACK_ENV_FILE ?? '').trim(); + assert.ok(envPath, 'expected dry-run to include HAPPIER_STACK_ENV_FILE'); + const stackName = String(dryData?.env?.HAPPIER_STACK_STACK ?? '').trim(); + assert.ok(stackName, 'expected dry-run to include HAPPIER_STACK_STACK'); + + // Ensure env exists but does not contain a server port pin yet. + mkdirSync(dirname(envPath), { recursive: true }); + writeFileSync(envPath, ['CUSTOM_KEY=1', ''].join('\n')); + + // Reserve the first stable port to force the wrapper to pick the next free one and persist it. + const reserved = await reserveStableStartPort({ + stackName, + baseCandidates: [52005, 54005, 56005, 58005], + range: 2000, + }); + try { + const res = await runNode( + [join(packageRoot, 'scripts', 'repo_local.mjs'), 'service', 'status'], + { + cwd: repoRoot, + env: { + ...process.env, + HAPPIER_STACK_STORAGE_DIR: stacksRoot, + HAPPIER_STACK_SERVER_PORT_BASE: String(reserved.base), + HAPPIER_STACK_SERVER_PORT_RANGE: String(reserved.range), + HAPPIER_STACK_REPO_LOCAL_PREFLIGHT_ONLY: '1', + }, + } + ); + assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + } finally { + await new Promise((resolve) => reserved.server.close(() => resolve())); + } + + const updated = readFileSync(envPath, 'utf-8'); + assert.match(updated, /\bCUSTOM_KEY=1\b/, `expected user key to be preserved:\n${updated}`); + const m = updated.match(/^HAPPIER_STACK_SERVER_PORT=(\d+)$/m); + assert.ok(m, `expected wrapper to persist HAPPIER_STACK_SERVER_PORT:\n${updated}`); + const pinned = Number(m?.[1] ?? ''); + assert.ok(Number.isFinite(pinned) && pinned > 0, `expected pinned port to be numeric, got: ${m?.[1]}`); + assert.ok( + pinned >= reserved.base && pinned < reserved.base + reserved.range, + `expected pinned port within range [${reserved.base}, ${reserved.base + reserved.range}): ${pinned}` + ); + assert.notEqual(pinned, reserved.startPort, `expected wrapper to avoid occupied start port ${reserved.startPort}, got: ${pinned}`); + } finally { + rmSync(stacksRoot, { recursive: true, force: true }); + } +}); diff --git a/apps/stack/scripts/root_package_repo_local_scripts.test.mjs b/apps/stack/scripts/root_package_repo_local_scripts.test.mjs index bef255cf7..53c7dd7b7 100644 --- a/apps/stack/scripts/root_package_repo_local_scripts.test.mjs +++ b/apps/stack/scripts/root_package_repo_local_scripts.test.mjs @@ -19,6 +19,9 @@ test('repo root package.json exposes repo-local hstack scripts', async () => { assert.equal(scripts.start, 'node ./apps/stack/scripts/repo_local.mjs start'); assert.equal(scripts.build, 'node ./apps/stack/scripts/repo_local.mjs build'); assert.equal(scripts.tui, 'node ./apps/stack/scripts/repo_local.mjs tui'); + assert.equal(scripts['tui:with-mobile'], 'node ./apps/stack/scripts/repo_local.mjs tui dev --mobile'); + assert.equal(scripts['cli:activate'], 'node ./apps/stack/scripts/repo_cli_activate.mjs'); + assert.equal(scripts['cli:activate:path'], 'node ./apps/stack/scripts/repo_cli_activate.mjs --install-path'); assert.equal(scripts.auth, 'node ./apps/stack/scripts/repo_local.mjs auth'); assert.equal(scripts.daemon, 'node ./apps/stack/scripts/repo_local.mjs daemon'); assert.equal(scripts.eas, 'node ./apps/stack/scripts/repo_local.mjs eas'); @@ -31,5 +34,13 @@ test('repo root package.json exposes repo-local hstack scripts', async () => { assert.equal(scripts.remote, 'node ./apps/stack/scripts/repo_local.mjs remote'); assert.equal(scripts.setup, 'node ./apps/stack/scripts/repo_local.mjs setup'); assert.equal(scripts.service, 'node ./apps/stack/scripts/repo_local.mjs service'); + assert.equal(scripts.logs, 'node ./apps/stack/scripts/repo_local.mjs logs --follow'); + assert.equal(scripts['logs:all'], 'node ./apps/stack/scripts/repo_local.mjs logs --follow --component=all'); + assert.equal(scripts['logs:server'], 'node ./apps/stack/scripts/repo_local.mjs logs --follow --component=server'); + assert.equal(scripts['logs:expo'], 'node ./apps/stack/scripts/repo_local.mjs logs --follow --component=expo'); + assert.equal(scripts['logs:ui'], 'node ./apps/stack/scripts/repo_local.mjs logs --follow --component=ui'); + assert.equal(scripts['logs:daemon'], 'node ./apps/stack/scripts/repo_local.mjs logs --follow --component=daemon'); + assert.equal(scripts['logs:service'], 'node ./apps/stack/scripts/repo_local.mjs logs --follow --component=service'); assert.equal(scripts.tailscale, 'node ./apps/stack/scripts/repo_local.mjs tailscale'); + assert.equal(scripts.env, 'node ./apps/stack/scripts/repo_local.mjs env'); }); diff --git a/apps/stack/scripts/self_host_launchd.real.integration.test.mjs b/apps/stack/scripts/self_host_launchd.real.integration.test.mjs index 195cd017a..3db4dc5aa 100644 --- a/apps/stack/scripts/self_host_launchd.real.integration.test.mjs +++ b/apps/stack/scripts/self_host_launchd.real.integration.test.mjs @@ -108,6 +108,8 @@ test( const serverPort = await reserveLocalhostPort(); const commonEnv = { PATH: process.env.PATH ?? '', + HOME: process.env.HOME ?? '', + USER: process.env.USER ?? '', HAPPIER_SELF_HOST_INSTALL_ROOT: installRoot, HAPPIER_SELF_HOST_BIN_DIR: binDir, HAPPIER_SELF_HOST_CONFIG_DIR: configDir, @@ -127,7 +129,6 @@ test( let installSucceeded = false; t.after(() => { - if (!installSucceeded) return; run( hstackPath, ['self-host', 'uninstall', '--channel=preview', '--mode=user', '--yes', '--purge-data', '--json'], @@ -160,7 +161,7 @@ test( ['self-host', 'status', '--channel=preview', '--mode=user', '--json'], { label: 'self-host-launchd', env: commonEnv, allowFail: true, timeoutMs: 45_000, cwd: sandboxDir } ); - const launchctlList = run('launchctl', ['list'], { label: 'self-host-launchd', allowFail: true, timeoutMs: 20_000 }); + const launchctlList = run('launchctl', ['list', serviceName], { label: 'self-host-launchd', allowFail: true, timeoutMs: 20_000 }); const launchctlPrint = run('launchctl', ['print', launchctlPrintTarget(serviceName)], { label: 'self-host-launchd', allowFail: true, timeoutMs: 20_000 }); const outTail = run('tail', ['-n', '200', serverOutLog], { label: 'self-host-launchd', allowFail: true, timeoutMs: 10_000 }); const errTail = run('tail', ['-n', '200', serverErrLog], { label: 'self-host-launchd', allowFail: true, timeoutMs: 10_000 }); diff --git a/apps/stack/scripts/self_host_runtime.mjs b/apps/stack/scripts/self_host_runtime.mjs index e1d394a9f..9a82fbbb2 100644 --- a/apps/stack/scripts/self_host_runtime.mjs +++ b/apps/stack/scripts/self_host_runtime.mjs @@ -9,6 +9,7 @@ import { mkdtemp, readFile, readdir, + rename, rm, stat, symlink, @@ -1287,7 +1288,7 @@ export function resolveMinisignPublicKeyText(env = process.env) { return inline || DEFAULT_MINISIGN_PUBLIC_KEY; } -async function installBinaryAtomically({ sourceBinaryPath, targetBinaryPath, previousBinaryPath, versionedTargetPath }) { +export async function installBinaryAtomically({ sourceBinaryPath, targetBinaryPath, previousBinaryPath, versionedTargetPath }) { await mkdir(dirname(targetBinaryPath), { recursive: true }); await mkdir(dirname(versionedTargetPath), { recursive: true }); const stagedPath = `${targetBinaryPath}.new`; @@ -1299,9 +1300,30 @@ async function installBinaryAtomically({ sourceBinaryPath, targetBinaryPath, pre } await copyFile(stagedPath, versionedTargetPath); await chmod(versionedTargetPath, 0o755).catch(() => {}); - await rm(stagedPath, { force: true }); + + // Replacing an on-disk executable that may currently be running can fail with ETXTBSY if we try to + // write over the existing path. Prefer an atomic rename/swap on POSIX so the running process keeps + // using the old inode while new spawns see the new binary. + if (process.platform !== 'win32') { + await rename(stagedPath, targetBinaryPath).catch(async (e) => { + const code = String(e?.code ?? ''); + // Best-effort fallback (should be rare): keep behavior working even on filesystems where rename fails. + if (code === 'EXDEV') { + await copyFile(versionedTargetPath, targetBinaryPath); + await chmod(targetBinaryPath, 0o755).catch(() => {}); + await rm(stagedPath, { force: true }); + return; + } + throw e; + }); + await chmod(targetBinaryPath, 0o755).catch(() => {}); + return; + } + + // Windows does not reliably support overwrite semantics for rename. await copyFile(versionedTargetPath, targetBinaryPath); await chmod(targetBinaryPath, 0o755).catch(() => {}); + await rm(stagedPath, { force: true }); } async function syncSelfHostSqliteMigrations({ artifactRootDir, targetDir }) { diff --git a/apps/stack/scripts/self_host_runtime.test.mjs b/apps/stack/scripts/self_host_runtime.test.mjs index a4a04bbd6..e3d2de6c6 100644 --- a/apps/stack/scripts/self_host_runtime.test.mjs +++ b/apps/stack/scripts/self_host_runtime.test.mjs @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import test from 'node:test'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { parseSelfHostInvocation, @@ -26,6 +26,7 @@ import { normalizeSelfHostAutoUpdateState, decideSelfHostAutoUpdateReconcile, mergeEnvTextWithDefaults, + installBinaryAtomically, } from './self_host_runtime.mjs'; function b64(buf) { @@ -259,6 +260,59 @@ test('self-host release installer ignores extra root entries when extracting bun assert.equal(String(raw.stdout ?? '').trim(), 'ok'); }); +test('installBinaryAtomically swaps a running binary on Linux without ETXTBSY', async (t) => { + if (process.platform !== 'linux') { + t.skip('ETXTBSY behavior is Linux-specific'); + return; + } + + const sleepPath = '/bin/sleep'; + const truePath = '/bin/true'; + if (spawnSync('bash', ['-lc', `test -x "${sleepPath}" && test -x "${truePath}"`], { stdio: 'ignore' }).status !== 0) { + t.skip('requires /bin/sleep and /bin/true'); + return; + } + + const tmp = await mkdtemp(join(tmpdir(), 'happier-self-host-etxtbsy-')); + t.after(() => { + spawnSync('bash', ['-lc', `rm -rf "${tmp.replaceAll('"', '\\"')}"`], { stdio: 'ignore' }); + }); + + const targetBinaryPath = join(tmp, 'bin', 'happier-server'); + const previousBinaryPath = join(tmp, 'bin', 'happier-server.previous'); + const versioned1 = join(tmp, 'versions', 'happier-server-1'); + const versioned2 = join(tmp, 'versions', 'happier-server-2'); + + await installBinaryAtomically({ + sourceBinaryPath: sleepPath, + targetBinaryPath, + previousBinaryPath, + versionedTargetPath: versioned1, + }); + + const child = spawn(targetBinaryPath, ['30'], { stdio: 'ignore' }); + t.after(() => { + try { + child.kill('SIGKILL'); + } catch { + // ignore + } + }); + + // Wait briefly for the process to enter the running state. + await new Promise((r) => setTimeout(r, 200)); + + await installBinaryAtomically({ + sourceBinaryPath: truePath, + targetBinaryPath, + previousBinaryPath, + versionedTargetPath: versioned2, + }); + + const ran = spawnSync(targetBinaryPath, [], { encoding: 'utf-8' }); + assert.equal(ran.status, 0, `expected swapped binary to run cleanly, got:\n${ran.stderr || ran.stdout || ''}`); +}); + test('resolveExtractedUiWebBundleRootDir picks the directory that contains index.html', async (t) => { const tmp = await mkdtemp(join(tmpdir(), 'happier-self-host-ui-root-test-')); t.after(async () => { diff --git a/apps/stack/scripts/service.mjs b/apps/stack/scripts/service.mjs index b397c82fa..6d25da050 100644 --- a/apps/stack/scripts/service.mjs +++ b/apps/stack/scripts/service.mjs @@ -24,6 +24,11 @@ import { resolvePreferredStackDaemonStatePaths, resolveStackCredentialPaths, } from './utils/auth/credentials_paths.mjs'; +import { + resolveAutostartEnvFilePath, + resolveAutostartLogPaths, + resolveAutostartWorkingDirectory, +} from './utils/service/stack_autostart_resolution.mjs'; /** * Manage the autostart service installed by `hstack bootstrap -- --autostart`. @@ -47,7 +52,7 @@ function getUid() { return Number.isFinite(n) ? n : null; } -function getAutostartEnv({ rootDir, mode }) { +function getAutostartEnv({ mode, systemUserHomeDir } = {}) { // IMPORTANT: // LaunchAgents should NOT bake the entire config into the plist, because that would require // reinstalling the service for any config change (server flavor, worktrees, ports, etc). @@ -60,15 +65,14 @@ function getAutostartEnv({ rootDir, mode }) { // Main installs: // - default to the main stack env (outside the repo): ~/.happier/stacks/main/env - const stacksEnvFile = process.env.HAPPIER_STACK_ENV_FILE?.trim() ? process.env.HAPPIER_STACK_ENV_FILE.trim() : ''; - const envFileRaw = stacksEnvFile || resolveStackEnvPath('main').envPath; - - // For system services, prefer a dynamic %h-based default when no explicit env file is set. - // This avoids accidentally pinning root's stack env when the service will run as another user. - const envFile = - mode === 'system' && !stacksEnvFile - ? '%h/.happier/stacks/main/env' - : envFileRaw; + const explicitEnvFilePath = process.env.HAPPIER_STACK_ENV_FILE?.trim() ? process.env.HAPPIER_STACK_ENV_FILE.trim() : ''; + const defaultMainEnvFilePath = resolveStackEnvPath('main').envPath; + const envFile = resolveAutostartEnvFilePath({ + mode, + explicitEnvFilePath, + defaultMainEnvFilePath, + systemUserHomeDir, + }); return { HAPPIER_STACK_ENV_FILE: envFile, @@ -148,8 +152,19 @@ export async function installService({ mode = 'user', systemUser = null } = {}) } ensureLinuxSystemModeSupported({ mode }); const rootDir = getRootDir(import.meta.url); - const { label, stdoutPath, stderrPath, baseDir } = getDefaultAutostartPaths(); - const env = getAutostartEnv({ rootDir, mode }); + const systemUserHomeDir = mode === 'system' && systemUser ? await resolveHomeDirForUser(systemUser) : ''; + const defaults = getDefaultAutostartPaths(); + const env = getAutostartEnv({ mode, systemUserHomeDir }); + const { baseDir, stdoutPath, stderrPath } = resolveAutostartLogPaths({ + mode, + hasStorageDirOverride: Boolean((process.env.HAPPIER_STACK_STORAGE_DIR ?? '').trim()), + systemUserHomeDir, + stackName: defaults.stackName, + defaultBaseDir: defaults.baseDir, + defaultStdoutPath: defaults.stdoutPath, + defaultStderrPath: defaults.stderrPath, + }); + const { label } = defaults; // Ensure the env file exists so the service never points at a missing path. try { const envFile = env.HAPPIER_STACK_ENV_FILE; @@ -164,12 +179,14 @@ export async function installService({ mode = 'user', systemUser = null } = {}) // ignore } const programArgs = await resolveStackAutostartProgramArgs({ rootDir, mode, systemUser }); - const workingDirectory = - process.platform === 'linux' - ? '%h' - : process.platform === 'darwin' - ? resolveInstalledCliRoot(rootDir) - : baseDir; + const workingDirectory = resolveAutostartWorkingDirectory({ + platform: process.platform, + mode, + defaultHomeDir: homedir(), + systemUserHomeDir, + baseDir, + installedCliRoot: resolveInstalledCliRoot(rootDir), + }); await installManagedService({ platform: process.platform, @@ -252,109 +269,6 @@ function systemdUnitName() { return `${label}.service`; } -function systemdUnitPath() { - return join(homedir(), '.config', 'systemd', 'user', systemdUnitName()); -} - -function systemdEnvLines(env) { - return Object.entries(env) - .map(([k, v]) => `Environment=${k}=${String(v)}`) - .join('\n'); -} - -async function ensureSystemdUserServiceEnabled({ rootDir, label, env }) { - const unitPath = systemdUnitPath(); - await mkdir(dirname(unitPath), { recursive: true }); - const hstackShim = join(getCanonicalHomeDir(), 'bin', 'hstack'); - const entry = existsSync(hstackShim) ? hstackShim : join(rootDir, 'bin', 'hstack.mjs'); - const exec = existsSync(hstackShim) ? entry : `${process.execPath} ${entry}`; - - const unit = `[Unit] -Description=Happier Stack (${label}) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -WorkingDirectory=%h -${systemdEnvLines(env)} -ExecStart=${exec} start -Restart=always -RestartSec=2 - -[Install] -WantedBy=default.target -`; - - await writeFile(unitPath, unit, 'utf-8'); - await runCapture('systemctl', ['--user', 'daemon-reload']).catch(() => {}); - await run('systemctl', ['--user', 'enable', '--now', systemdUnitName()]); -} - -async function ensureSystemdSystemServiceEnabled({ rootDir, label, env, systemUser }) { - const { unitName, unitPath } = getSystemdUnitInfo({ mode: 'system' }); - let userLine = ''; - let hstackShim = join(getCanonicalHomeDir(), 'bin', 'hstack'); - if (systemUser) { - const home = await resolveHomeDirForUser(systemUser); - if (home) { - hstackShim = join(home, '.happier-stack', 'bin', 'hstack'); - } - userLine = `User=${systemUser}\n`; - } - - const entry = existsSync(hstackShim) ? hstackShim : join(rootDir, 'bin', 'hstack.mjs'); - const exec = existsSync(hstackShim) ? entry : `${process.execPath} ${entry}`; - - const unit = `[Unit] -Description=Happier Stack (${label}) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -${userLine}WorkingDirectory=%h -${systemdEnvLines(env)} -ExecStart=${exec} start -Restart=always -RestartSec=2 - -[Install] -WantedBy=multi-user.target -`; - - try { - await writeFile(unitPath, unit, 'utf-8'); - } catch (e) { - const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : ''; - if (code === 'EACCES' || code === 'EPERM') { - throw new Error(`[local] --mode=system requires root (run with sudo).`); - } - throw e; - } - await runCapture('systemctl', ['daemon-reload']).catch(() => {}); - await run('systemctl', ['enable', '--now', unitName]); -} - -async function ensureSystemdUserServiceDisabled({ remove } = {}) { - await runCapture('systemctl', ['--user', 'disable', '--now', systemdUnitName()]).catch(() => {}); - await runCapture('systemctl', ['--user', 'stop', systemdUnitName()]).catch(() => {}); - if (remove) { - await rm(systemdUnitPath(), { force: true }).catch(() => {}); - await runCapture('systemctl', ['--user', 'daemon-reload']).catch(() => {}); - } -} - -async function ensureSystemdSystemServiceDisabled({ remove } = {}) { - const { unitName, unitPath } = getSystemdUnitInfo({ mode: 'system' }); - await runCapture('systemctl', ['disable', '--now', unitName]).catch(() => {}); - await runCapture('systemctl', ['stop', unitName]).catch(() => {}); - if (remove) { - await rm(unitPath, { force: true }).catch(() => {}); - await runCapture('systemctl', ['daemon-reload']).catch(() => {}); - } -} - async function systemdStatus() { await run('systemctl', ['--user', 'status', systemdUnitName(), '--no-pager']); } diff --git a/apps/stack/scripts/stack/stack_environment.mjs b/apps/stack/scripts/stack/stack_environment.mjs index 5e908b5ed..237214ebb 100644 --- a/apps/stack/scripts/stack/stack_environment.mjs +++ b/apps/stack/scripts/stack/stack_environment.mjs @@ -1,7 +1,8 @@ import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { ensureDir, readTextOrEmpty } from '../utils/fs/ops.mjs'; import { parseEnvToObject } from '../utils/env/dotenv.mjs'; -import { getRepoDir, resolveStackEnvPath } from '../utils/paths/paths.mjs'; +import { getWorkspaceDir, resolveStackEnvPath } from '../utils/paths/paths.mjs'; import { stackExistsSync } from '../utils/stack/stacks.mjs'; import { STACK_WRAPPER_PRESERVE_KEYS, scrubHappierStackEnv } from '../utils/env/scrub_env.mjs'; import { applyStackActiveServerScopeEnv } from '../utils/auth/stable_scope_id.mjs'; @@ -43,7 +44,8 @@ export function resolveDefaultRepoEnv({ rootDir }) { // // Default: use the workspace clone (<workspace>/happier), regardless of any current // one-off repo/worktree selection in the user's environment. - const repoDir = getRepoDir(rootDir, { ...process.env, HAPPIER_STACK_REPO_DIR: '' }); + const workspaceDir = getWorkspaceDir(rootDir, { ...process.env, HAPPIER_STACK_REPO_DIR: '' }); + const repoDir = join(workspaceDir, 'main'); return { HAPPIER_STACK_REPO_DIR: repoDir }; } diff --git a/apps/stack/scripts/tailscale.mjs b/apps/stack/scripts/tailscale.mjs index 30bf3ef3e..bda40e74e 100644 --- a/apps/stack/scripts/tailscale.mjs +++ b/apps/stack/scripts/tailscale.mjs @@ -4,6 +4,7 @@ import { run, runCapture } from './utils/proc/proc.mjs'; import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs'; import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs'; import { getInternalServerUrl } from './utils/server/urls.mjs'; +import { getStackName, resolveStackEnvPath } from './utils/paths/paths.mjs'; import { resolveCommandPath } from './utils/proc/commands.mjs'; import { constants } from 'node:fs'; import { access } from 'node:fs/promises'; @@ -446,6 +447,9 @@ async function main() { 'If you really want this, set: HAPPIER_STACK_SANDBOX_ALLOW_GLOBAL=1' ); } + const stackName = getStackName(process.env); + const envPath = (process.env.HAPPIER_STACK_ENV_FILE ?? '').toString().trim() || resolveStackEnvPath(stackName, process.env).envPath; + const { upstream } = getServeConfig(internalServerUrl); const res = await tailscaleServeEnable({ internalServerUrl }); if (res?.enableUrl && !res?.httpsUrl) { printResult({ @@ -453,6 +457,9 @@ async function main() { data: { ok: true, httpsUrl: null, enableUrl: res.enableUrl }, text: `${green('✓')} tailscale serve needs one-time approval in your tailnet.\n` + + `${dim('stack:')} ${stackName}\n` + + `${dim('upstream:')} ${upstream}\n` + + `${dim('env:')} ${envPath}\n` + `${dim('Open:')} ${cyan(res.enableUrl)}`, }); return; @@ -460,7 +467,15 @@ async function main() { printResult({ json, data: { ok: true, httpsUrl: res.httpsUrl ?? null }, - text: res.httpsUrl ? `${green('✓')} tailscale serve enabled: ${cyan(res.httpsUrl)}` : `${green('✓')} tailscale serve enabled`, + text: res.httpsUrl + ? `${green('✓')} tailscale serve enabled: ${cyan(res.httpsUrl)}\n` + + `${dim('stack:')} ${stackName}\n` + + `${dim('upstream:')} ${upstream}\n` + + `${dim('env:')} ${envPath}` + : `${green('✓')} tailscale serve enabled\n` + + `${dim('stack:')} ${stackName}\n` + + `${dim('upstream:')} ${upstream}\n` + + `${dim('env:')} ${envPath}`, }); return; } diff --git a/apps/stack/scripts/tailscale_cmd_output.test.mjs b/apps/stack/scripts/tailscale_cmd_output.test.mjs new file mode 100644 index 000000000..aa7fff838 --- /dev/null +++ b/apps/stack/scripts/tailscale_cmd_output.test.mjs @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function runNode(args, { cwd, env }) { + return new Promise((resolve, reject) => { + const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d) => (stdout += String(d))); + proc.stderr.on('data', (d) => (stderr += String(d))); + proc.on('error', reject); + proc.on('exit', (code, signal) => resolve({ code: code ?? (signal ? 1 : 0), signal, stdout, stderr })); + }); +} + +function stripAnsi(s) { + return String(s ?? '').replace(/\x1b\[[0-9;]*m/g, ''); +} + +test('tailscale enable output includes stack context + upstream', async () => { + const scriptsDir = dirname(fileURLToPath(import.meta.url)); + const packageRoot = dirname(scriptsDir); // apps/stack + const repoRoot = dirname(dirname(packageRoot)); // repo root + + const tmp = mkdtempSync(join(tmpdir(), 'happier-tailscale-test-')); + try { + const binDir = join(tmp, 'bin'); + mkdirSync(binDir, { recursive: true }); + const tailscaleBin = join(binDir, 'tailscale'); + const internalPort = 55555; + const httpsUrl = 'https://happier-test.ts.net'; + + // Fake tailscale CLI: enough behavior for `tailscale serve --bg <upstream>` + `tailscale serve status`. + writeFileSync( + tailscaleBin, + [ + '#!/usr/bin/env node', + 'const args = process.argv.slice(2);', + "if (args[0] === 'serve' && args[1] === 'status') {", + ` process.stdout.write('${httpsUrl}\\n');`, + ` process.stdout.write('|-- / proxy http://127.0.0.1:${internalPort}\\n');`, + ' process.exit(0);', + '}', + "if (args[0] === 'serve' && args.includes('--bg')) {", + ' process.exit(0);', + '}', + "if (args[0] === 'serve' && args[1] === 'reset') {", + ' process.exit(0);', + '}', + 'process.exit(0);', + '', + ].join('\n') + ); + chmodSync(tailscaleBin, 0o755); + + const envPath = join(tmp, 'stack.env'); + writeFileSync(envPath, ''); + + const res = await runNode([join(packageRoot, 'scripts', 'tailscale.mjs'), 'enable'], { + cwd: repoRoot, + env: { + ...process.env, + HAPPIER_STACK_TAILSCALE_BIN: tailscaleBin, + HAPPIER_STACK_SERVER_PORT: String(internalPort), + HAPPIER_STACK_STACK: 'repo-test-1234', + HAPPIER_STACK_ENV_FILE: envPath, + // Prevent any other behavior from probing global state. + HAPPIER_STACK_SANDBOX_ALLOW_GLOBAL: '1', + }, + }); + + assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`); + const out = stripAnsi(res.stdout); + assert.match(out, /tailscale serve enabled/i); + assert.match(out, new RegExp(`\\b${httpsUrl.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\b`)); + assert.match(out, /\bstack:\s+repo-test-1234\b/i); + assert.match(out, new RegExp(`\\bupstream:\\s+http://127\\.0\\.0\\.1:${internalPort}\\b`, 'i')); + assert.match(out, new RegExp(`\\benv:\\s+${envPath.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\b`)); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + diff --git a/apps/stack/scripts/tui.mjs b/apps/stack/scripts/tui.mjs index f6070f484..b69776d2e 100644 --- a/apps/stack/scripts/tui.mjs +++ b/apps/stack/scripts/tui.mjs @@ -32,6 +32,8 @@ import { detachTuiStdinForChild, waitForEnter } from './utils/tui/stdin_handoff. import { waitForHappierHealthOk } from './utils/server/server.mjs'; import { buildTuiAuthArgs, buildTuiDaemonStartArgs, shouldHoldAfterAuthExit } from './utils/tui/actions.mjs'; import { shouldAttemptTuiDaemonAutostart } from './utils/tui/daemon_autostart.mjs'; +import { reconcileDaemonPaneAfterDaemonStarts } from './utils/tui/daemon_pane_reconcile.mjs'; +import { buildScriptPtyArgs } from './utils/tui/script_pty_command.mjs'; function nowTs() { const d = new Date(); @@ -544,10 +546,17 @@ async function main() { let child = null; const spawnForwardedChild = () => { + const pty = wantsPty + ? buildScriptPtyArgs({ + platform: process.platform, + file: '/dev/null', + command: [process.execPath, happysBin, ...forwarded], + }) + : null; const proc = wantsPty ? // Use a pseudo-terminal so tools like Expo print QR/status output that they hide in non-TTY mode. // `script` is available by default on macOS (and common on Linux). - spawn('script', ['-q', '/dev/null', process.execPath, happysBin, ...forwarded], { + spawn(pty.cmd, pty.args, { cwd: rootDir, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'], @@ -561,7 +570,7 @@ async function main() { }); logOrch( - `spawned: ${wantsPty ? 'script -q /dev/null ' : ''}node ${happysBin} ${forwarded.join(' ')} (pid=${proc.pid})` + `spawned: ${wantsPty ? `${pty.cmd} ${pty.args.join(' ')} ` : ''}node ${happysBin} ${forwarded.join(' ')} (pid=${proc.pid})` ); const buf = { out: '', err: '' }; @@ -607,131 +616,136 @@ async function main() { panes[idx].lines = [`summary error: ${e instanceof Error ? e.message : String(e)}`]; } - // Daemon pane (best-effort): if daemon isn't running because credentials are missing, show guidance. - try { - const daemonIdx = paneIndexById.get('daemon'); - if (stackName) { - const runtimePath = getStackRuntimeStatePath(stackName); - const runtime = await readStackRuntimeStateFile(runtimePath); - const daemonPid = Number(runtime?.processes?.daemonPid); - if (!Number.isFinite(daemonPid) || daemonPid <= 1) { - const { baseDir } = resolveStackEnvPath(stackName); - const serverPort = Number(runtime?.ports?.server); - const internalServerUrl = - Number.isFinite(serverPort) && serverPort > 0 ? `http://127.0.0.1:${serverPort}` : ''; - const cliHomeDir = join(baseDir, 'cli'); - const authed = internalServerUrl - ? hasStackCredentials({ - cliHomeDir, - serverUrl: internalServerUrl, - env: applyTuiStackAuthScopeEnv({ env: process.env, stackName }), - }) - : hasStackCredentials({ cliHomeDir, serverUrl: '', env: applyTuiStackAuthScopeEnv({ env: process.env, stackName }) }); - - const startDaemon = parseStartDaemonFlagFromEnv(process.env); - const notice = buildDaemonAuthNotice({ - stackName, - internalServerUrl, - daemonPid: null, - authed, - startDaemon, - }); - if (notice.show) { - panes[daemonIdx].visible = true; - panes[daemonIdx].title = notice.paneTitle || panes[daemonIdx].title; - const preserve = - daemonAutostartInProgress || - (daemonAutostartLastAttemptAtMs > 0 && Date.now() - daemonAutostartLastAttemptAtMs < 6_000); - if (!preserve || panes[daemonIdx].lines.length === 0) { - panes[daemonIdx].lines = styleDaemonNoticeLines(notice.paneLines || panes[daemonIdx].lines); - } - if (!sawDaemonAuthRequired && notice.paneTitle === 'daemon (SIGN-IN REQUIRED)') { - sawDaemonAuthRequired = true; - // One-shot nudge: focus the daemon pane so users see the message before opening the UI. - if (focused === paneIndexById.get('local')) { - focused = daemonIdx; - } - } - } - - const isStartLike = isTuiStartLikeForwardedArgs(forwarded); - const minIntervalRaw = (process.env.HAPPIER_STACK_TUI_DAEMON_AUTOSTART_MIN_INTERVAL_MS ?? '').toString().trim(); - const minIntervalMs = minIntervalRaw ? Number(minIntervalRaw) : 12_000; - const shouldAutostart = shouldAttemptTuiDaemonAutostart({ - stackName, - isStartLike, - startDaemon, - internalServerUrl, - authed, - daemonPid: null, - inProgress: daemonAutostartInProgress, - lastAttemptAtMs: daemonAutostartLastAttemptAtMs, - nowMs: Date.now(), - minIntervalMs, - }); - - if (shouldAutostart) { - daemonAutostartInProgress = true; - daemonAutostartLastAttemptAtMs = Date.now(); - panes[daemonIdx].visible = true; - panes[daemonIdx].title = 'daemon (STARTING)'; - pushLine(panes[daemonIdx], 'starting daemon...'); - scheduleRender(); - - void (async () => { - try { - // Best-effort: only start daemon once server is responding. - await waitForHappierHealthOk(internalServerUrl, { timeoutMs: 10_000, intervalMs: 250 }); - - const daemonArgs = buildTuiDaemonStartArgs({ happysBin, stackName }); - const attemptLines = []; - await new Promise((resolvePromise) => { - const proc = spawn(process.execPath, daemonArgs, { - cwd: rootDir, - env: { ...process.env, HAPPIER_STACK_TUI: '1' }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - const write = (chunk) => { - const s = String(chunk ?? ''); - for (const line of s.split(/\r?\n/)) { - if (!line.trim()) continue; - attemptLines.push(line); - pushLine(panes[daemonIdx], line); - } - scheduleRender(); - }; - - proc.stdout?.on('data', write); - proc.stderr?.on('data', write); - proc.on('exit', () => resolvePromise()); - proc.on('error', () => resolvePromise()); + // Daemon pane (best-effort): show sign-in guidance when credentials are missing, + // and clear stale guidance once the daemon starts. + try { + const daemonIdx = paneIndexById.get('daemon'); + if (stackName) { + const runtimePath = getStackRuntimeStatePath(stackName); + const runtime = await readStackRuntimeStateFile(runtimePath); + const daemonPid = Number(runtime?.processes?.daemonPid); + + const { baseDir } = resolveStackEnvPath(stackName); + const serverPort = Number(runtime?.ports?.server); + const internalServerUrl = + Number.isFinite(serverPort) && serverPort > 0 ? `http://127.0.0.1:${serverPort}` : ''; + const cliHomeDir = join(baseDir, 'cli'); + + const scopedEnv = applyTuiStackAuthScopeEnv({ env: process.env, stackName }); + const authed = internalServerUrl + ? hasStackCredentials({ cliHomeDir, serverUrl: internalServerUrl, env: scopedEnv }) + : hasStackCredentials({ cliHomeDir, serverUrl: '', env: scopedEnv }); + + const startDaemon = parseStartDaemonFlagFromEnv(process.env); + const notice = buildDaemonAuthNotice({ + stackName, + internalServerUrl, + daemonPid: Number.isFinite(daemonPid) && daemonPid > 1 ? daemonPid : null, + authed, + startDaemon, + }); + + if (notice.show) { + panes[daemonIdx].visible = true; + panes[daemonIdx].title = notice.paneTitle || panes[daemonIdx].title; + const preserve = + daemonAutostartInProgress || + (daemonAutostartLastAttemptAtMs > 0 && Date.now() - daemonAutostartLastAttemptAtMs < 6_000); + if (!preserve || panes[daemonIdx].lines.length === 0) { + panes[daemonIdx].lines = styleDaemonNoticeLines(notice.paneLines || panes[daemonIdx].lines); + } + if (!sawDaemonAuthRequired && notice.paneTitle === 'daemon (SIGN-IN REQUIRED)') { + sawDaemonAuthRequired = true; + if (focused === paneIndexById.get('local')) { + focused = daemonIdx; + } + } + } else { + const reconciled = reconcileDaemonPaneAfterDaemonStarts({ + title: panes[daemonIdx].title, + lines: panes[daemonIdx].lines, + daemonPid, + }); + panes[daemonIdx].title = reconciled.title; + panes[daemonIdx].lines = reconciled.lines; + } + + const isStartLike = isTuiStartLikeForwardedArgs(forwarded); + const minIntervalRaw = (process.env.HAPPIER_STACK_TUI_DAEMON_AUTOSTART_MIN_INTERVAL_MS ?? '').toString().trim(); + const minIntervalMs = minIntervalRaw ? Number(minIntervalRaw) : 12_000; + const shouldAutostart = shouldAttemptTuiDaemonAutostart({ + stackName, + isStartLike, + startDaemon, + internalServerUrl, + authed, + daemonPid: Number.isFinite(daemonPid) && daemonPid > 1 ? daemonPid : null, + inProgress: daemonAutostartInProgress, + lastAttemptAtMs: daemonAutostartLastAttemptAtMs, + nowMs: Date.now(), + minIntervalMs, + }); + + if (shouldAutostart) { + daemonAutostartInProgress = true; + daemonAutostartLastAttemptAtMs = Date.now(); + panes[daemonIdx].visible = true; + panes[daemonIdx].title = 'daemon (STARTING)'; + pushLine(panes[daemonIdx], 'starting daemon...'); + scheduleRender(); + + void (async () => { + try { + await waitForHappierHealthOk(internalServerUrl, { timeoutMs: 10_000, intervalMs: 250 }); + + const daemonArgs = buildTuiDaemonStartArgs({ happysBin, stackName }); + const attemptLines = []; + await new Promise((resolvePromise) => { + const proc = spawn(process.execPath, daemonArgs, { + cwd: rootDir, + env: { ...process.env, HAPPIER_STACK_TUI: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], }); - const combined = attemptLines.join('\n').toLowerCase(); - if (combined.includes('already running')) { - panes[daemonIdx].title = 'daemon (ALREADY RUNNING)'; - pushLine(panes[daemonIdx], 'daemon already running; no action needed'); - } else { - panes[daemonIdx].title = 'daemon (STARTED)'; - pushLine(panes[daemonIdx], 'daemon start completed'); - } - scheduleRender(); - } finally { - daemonAutostartInProgress = false; - try { - await refreshSummary(); - } catch { - // ignore - } - } - })(); - } - } - } - } catch { - // ignore - } + const write = (chunk) => { + const s = String(chunk ?? ''); + for (const line of s.split(/\r?\n/)) { + if (!line.trim()) continue; + attemptLines.push(line); + pushLine(panes[daemonIdx], line); + } + scheduleRender(); + }; + + proc.stdout?.on('data', write); + proc.stderr?.on('data', write); + proc.on('exit', () => resolvePromise()); + proc.on('error', () => resolvePromise()); + }); + + const combined = attemptLines.join('\n').toLowerCase(); + if (combined.includes('already running')) { + panes[daemonIdx].title = 'daemon (ALREADY RUNNING)'; + pushLine(panes[daemonIdx], 'daemon already running; no action needed'); + } else { + panes[daemonIdx].title = 'daemon (STARTED)'; + pushLine(panes[daemonIdx], 'daemon start completed'); + } + scheduleRender(); + } finally { + daemonAutostartInProgress = false; + try { + await refreshSummary(); + } catch { + // ignore + } + } + })(); + } + } + } catch { + // ignore + } // QR pane: driven by runtime state (expo port) and rendered independently of logs. try { diff --git a/apps/stack/scripts/utils/cli/cli_registry.mjs b/apps/stack/scripts/utils/cli/cli_registry.mjs index 9358163ff..76d5394e4 100644 --- a/apps/stack/scripts/utils/cli/cli_registry.mjs +++ b/apps/stack/scripts/utils/cli/cli_registry.mjs @@ -322,6 +322,13 @@ export function gethstackRegistry() { rootUsage: 'hstack service <install|uninstall|status|start|stop|restart|enable|disable|logs|tail>', description: 'LaunchAgent service management', }, + { + name: 'logs', + kind: 'node', + scriptRelPath: 'scripts/logs.mjs', + rootUsage: 'hstack logs [--component=auto|all|runner|server|expo|ui|daemon|service] [--lines N] [--follow]', + description: 'View stack logs (runner/server/expo/daemon/service)', + }, { name: 'menubar', kind: 'node', @@ -342,8 +349,7 @@ export function gethstackRegistry() { { name: 'stack:fix', kind: 'node', scriptRelPath: 'scripts/doctor.mjs', argsFromRest: (rest) => ['--fix', ...rest], hidden: true }, { name: 'cli:link', kind: 'node', scriptRelPath: 'scripts/cli-link.mjs', hidden: true }, - { name: 'logs', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['logs', ...rest], hidden: true }, - { name: 'logs:tail', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['tail', ...rest], hidden: true }, + { name: 'logs:tail', kind: 'node', scriptRelPath: 'scripts/logs.mjs', argsFromRest: (rest) => ['tail', ...rest], hidden: true }, { name: 'service:status', diff --git a/apps/stack/scripts/utils/dev/expo_dev.mjs b/apps/stack/scripts/utils/dev/expo_dev.mjs index 57b5d5718..f58078b62 100644 --- a/apps/stack/scripts/utils/dev/expo_dev.mjs +++ b/apps/stack/scripts/utils/dev/expo_dev.mjs @@ -10,6 +10,7 @@ import { writePidState, } from '../expo/expo.mjs'; import { pickExpoDevMetroPort } from '../expo/metro_ports.mjs'; +import { ensureEnvFileUpdated } from '../env/env_file.mjs'; import { isPidAlive, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs'; import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs'; import { expoSpawn } from '../expo/command.mjs'; @@ -428,7 +429,30 @@ export async function ensureDevExpoServer({ stackName, reservedPorts: reservedMetroPorts, }); + const forcedPortRaw = (baseEnv?.HAPPIER_STACK_EXPO_DEV_PORT ?? '').toString().trim(); + const forcedPortNum = Number(forcedPortRaw); + if ( + stackMode && + envPath && + forcedPortRaw && + Number.isFinite(forcedPortNum) && + forcedPortNum > 0 && + forcedPortNum !== metroPort + ) { + if (!quiet) { + // eslint-disable-next-line no-console + console.warn( + `[local] expo: requested metro port ${forcedPortNum} is not available; using ${metroPort}.\n` + + `[local] expo: updating ${envPath} so future runs keep stable ports.` + ); + } + await ensureEnvFileUpdated({ + envPath, + updates: [{ key: 'HAPPIER_STACK_EXPO_DEV_PORT', value: String(metroPort) }], + }).catch(() => {}); + } env.RCT_METRO_PORT = String(metroPort); + env.HAPPIER_STACK_EXPO_DEV_PORT = String(metroPort); const host = resolveExpoDevHost({ env }); const args = buildExpoStartArgs({ port: metroPort, diff --git a/apps/stack/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs b/apps/stack/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs index f89136e2a..f59759604 100644 --- a/apps/stack/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +++ b/apps/stack/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs @@ -297,3 +297,65 @@ test('ensureDevExpoServer in stack mode does not adopt port-only fallback as alr await rm(tmp, { recursive: true, force: true }); } }); + +test('ensureDevExpoServer updates envPath when forced expo port is occupied', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'hstack-expo-update-env-port-')); + const children = []; + let metro = null; + try { + const uiDir = join(tmp, 'ui'); + await mkdir(join(uiDir, 'node_modules', '.bin'), { recursive: true }); + await mkdir(join(uiDir, 'node_modules'), { recursive: true }); + await writeFile(join(uiDir, 'package.json'), JSON.stringify({ name: 'fake-ui', private: true }) + '\n', 'utf-8'); + + const expoBin = join(uiDir, 'node_modules', '.bin', 'expo'); + await writeFile( + expoBin, + ['#!/usr/bin/env node', "setInterval(() => {}, 1000);"].join('\n') + '\n', + 'utf-8' + ); + await chmod(expoBin, 0o755); + + const status = await listenMetroStatusServer(); + metro = status.server; + const occupiedPort = status.port; + + const envPath = join(tmp, 'stack.env'); + await writeFile(envPath, `CUSTOM_KEY=1\nHAPPIER_STACK_EXPO_DEV_PORT=${occupiedPort}\n`, 'utf-8'); + + const result = await ensureDevExpoServer({ + startUi: true, + startMobile: false, + uiDir, + autostart: { baseDir: tmp }, + baseEnv: { + ...process.env, + HAPPIER_STACK_EXPO_DEV_PORT: String(occupiedPort), + HAPPIER_STACK_EXPO_DEV_PORT_STRATEGY: 'stable', + HAPPIER_STACK_EXPO_DEV_PORT_BASE: '51000', + HAPPIER_STACK_EXPO_DEV_PORT_RANGE: '2000', + }, + apiServerUrl: 'http://127.0.0.1:1', + restart: false, + stackMode: true, + runtimeStatePath: null, + stackName: 'qa-agent-update-env', + envPath, + children, + quiet: true, + }); + + assert.equal(result.ok, true); + assert.notEqual(result.port, occupiedPort); + + const updated = await readFile(envPath, 'utf-8'); + assert.match(updated, /\bCUSTOM_KEY=1\b/); + assert.match(updated, new RegExp(`\\bHAPPIER_STACK_EXPO_DEV_PORT=${result.port}\\b`)); + } finally { + for (const child of children) { + killProcessTreeByPid(child?.pid); + } + await new Promise((resolve) => metro?.close(() => resolve())).catch(() => {}); + await rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/apps/stack/scripts/utils/expo/metro_ports.test.mjs b/apps/stack/scripts/utils/expo/metro_ports.test.mjs index 521277ed9..0e9249968 100644 --- a/apps/stack/scripts/utils/expo/metro_ports.test.mjs +++ b/apps/stack/scripts/utils/expo/metro_ports.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import net from 'node:net'; +import { createServer } from 'node:http'; import { pickMetroPort } from './metro_ports.mjs'; @@ -33,3 +34,57 @@ test('pickMetroPort does not reuse forced port when it is reserved', async () => }); assert.notEqual(picked, forced); }); + +test('pickMetroPort does not reuse forced port when it is occupied by a non-metro process', async () => { + const srv = createServer((req, res) => { + if (req.url === '/status') { + res.statusCode = 200; + res.end('nope'); + return; + } + res.statusCode = 404; + res.end('not found'); + }); + await new Promise((resolvePromise) => srv.listen(0, '127.0.0.1', resolvePromise)); + const addr = srv.address(); + const port = typeof addr === 'object' && addr ? addr.port : null; + if (!port) throw new Error('failed to bind test server'); + try { + const picked = await pickMetroPort({ + startPort: port, + forcedPort: String(port), + reservedPorts: new Set(), + host: '127.0.0.1', + }); + assert.notEqual(picked, port); + } finally { + await new Promise((resolvePromise) => srv.close(resolvePromise)); + } +}); + +test('pickMetroPort does not reuse forced port when it is occupied by a Metro-like /status responder', async () => { + const srv = createServer((req, res) => { + if (req.url === '/status') { + res.statusCode = 200; + res.end('packager-status:running'); + return; + } + res.statusCode = 404; + res.end('not found'); + }); + await new Promise((resolvePromise) => srv.listen(0, '127.0.0.1', resolvePromise)); + const addr = srv.address(); + const port = typeof addr === 'object' && addr ? addr.port : null; + if (!port) throw new Error('failed to bind test server'); + try { + const picked = await pickMetroPort({ + startPort: port, + forcedPort: String(port), + reservedPorts: new Set(), + host: '127.0.0.1', + }); + assert.notEqual(picked, port); + } finally { + await new Promise((resolvePromise) => srv.close(resolvePromise)); + } +}); diff --git a/apps/stack/scripts/utils/git/dev_checkout.mjs b/apps/stack/scripts/utils/git/dev_checkout.mjs index eb8b5196e..9ccbd2174 100644 --- a/apps/stack/scripts/utils/git/dev_checkout.mjs +++ b/apps/stack/scripts/utils/git/dev_checkout.mjs @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; -import { getDevRepoDir, getRepoDir } from '../paths/paths.mjs'; +import { getDevRepoDir, getWorkspaceDir } from '../paths/paths.mjs'; import { runCapture } from '../proc/proc.mjs'; async function gitHasRemote({ repoDir, remote }) { @@ -53,7 +53,8 @@ export async function resolveDevPushRemote({ repoDir, env = process.env, preferr } export async function ensureDevCheckout({ rootDir, env = process.env, remote = '' } = {}) { - const mainDir = getRepoDir(rootDir, { ...env, HAPPIER_STACK_REPO_DIR: '' }); + const workspaceDir = getWorkspaceDir(rootDir, { ...env, HAPPIER_STACK_REPO_DIR: '' }); + const mainDir = join(workspaceDir, 'main'); const devDir = getDevRepoDir(rootDir, env); const devBranch = resolveDevBranchName(env); diff --git a/apps/stack/scripts/utils/paths/paths.mjs b/apps/stack/scripts/utils/paths/paths.mjs index 3211345c6..853dc83dc 100644 --- a/apps/stack/scripts/utils/paths/paths.mjs +++ b/apps/stack/scripts/utils/paths/paths.mjs @@ -125,9 +125,15 @@ export function getWorkspaceDir(cliRootDir = null, env = process.env) { export function getRepoDir(rootDir, env = process.env) { const fromEnv = normalizePathForEnv(rootDir, env.HAPPIER_STACK_REPO_DIR, env); const workspaceDir = getWorkspaceDir(rootDir, env); - const fallback = join(workspaceDir, 'main'); // Prefer explicitly configured repo dir (if set). + // Otherwise: + // - If this CLI root is inside a Happier monorepo checkout, treat that checkout as the repo dir. + // This enables "repo-local / stackless" usage without requiring a workspace/main checkout. + // - Else, fall back to <workspace>/main. + const inferredFromCliRoot = fromEnv ? '' : coerceHappyMonorepoRootFromPath(rootDir); + const fallback = inferredFromCliRoot || join(workspaceDir, 'main'); + const candidate = fromEnv || fallback; if (!candidate) return fallback; diff --git a/apps/stack/scripts/utils/paths/paths_monorepo.test.mjs b/apps/stack/scripts/utils/paths/paths_monorepo.test.mjs index a84492690..9e7c9e0aa 100644 --- a/apps/stack/scripts/utils/paths/paths_monorepo.test.mjs +++ b/apps/stack/scripts/utils/paths/paths_monorepo.test.mjs @@ -4,7 +4,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { getComponentDir, getComponentRepoDir } from './paths.mjs'; +import { getComponentDir, getComponentRepoDir, getRepoDir } from './paths.mjs'; async function withTempRoot(t) { const dir = await mkdtemp(join(tmpdir(), 'happier-stacks-paths-monorepo-')); @@ -25,6 +25,16 @@ async function writeHappyMonorepoStub({ rootDir }) { return monoRoot; } +async function writeHappyMonorepoStubAt({ monoRoot }) { + await mkdir(join(monoRoot, 'apps', 'ui'), { recursive: true }); + await mkdir(join(monoRoot, 'apps', 'cli'), { recursive: true }); + await mkdir(join(monoRoot, 'apps', 'server'), { recursive: true }); + await writeFile(join(monoRoot, 'apps', 'ui', 'package.json'), '{}\n', 'utf-8'); + await writeFile(join(monoRoot, 'apps', 'cli', 'package.json'), '{}\n', 'utf-8'); + await writeFile(join(monoRoot, 'apps', 'server', 'package.json'), '{}\n', 'utf-8'); + return monoRoot; +} + test('getComponentDir derives monorepo component package dirs from workspace/main', async (t) => { const rootDir = await withTempRoot(t); const env = { HAPPIER_STACK_WORKSPACE_DIR: rootDir }; @@ -56,3 +66,18 @@ test('getComponentDir normalizes HAPPIER_STACK_REPO_DIR that points inside the m env.HAPPIER_STACK_REPO_DIR = join(monoRoot, 'apps', 'cli', 'src'); assert.equal(getComponentDir(rootDir, 'happier-cli', env), join(monoRoot, 'apps', 'cli')); }); + +test('getRepoDir falls back to the monorepo containing the CLI root when HAPPIER_STACK_REPO_DIR is unset', async (t) => { + const tmpRoot = await withTempRoot(t); + + const workspaceDir = join(tmpRoot, 'workspace'); + const monoRoot = join(tmpRoot, 'happier'); + await writeHappyMonorepoStubAt({ monoRoot }); + + // Simulate running hstack from an activated local clone: + // rootDir is inside the monorepo, but there is no workspace/main checkout. + const rootDir = join(monoRoot, 'apps', 'stack'); + const env = { HAPPIER_STACK_WORKSPACE_DIR: workspaceDir }; + + assert.equal(getRepoDir(rootDir, env), monoRoot); +}); diff --git a/apps/stack/scripts/utils/proc/proc.mjs b/apps/stack/scripts/utils/proc/proc.mjs index 6ec0bd983..8733170a7 100644 --- a/apps/stack/scripts/utils/proc/proc.mjs +++ b/apps/stack/scripts/utils/proc/proc.mjs @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; -import { createWriteStream } from 'node:fs'; +import { createWriteStream, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; function nextLineBreakIndex(s) { const n = s.indexOf('\n'); @@ -33,6 +34,12 @@ function flushPrefixed(stream, prefix, bufState) { bufState.buf = ''; } +function sanitizeLogFileToken(raw) { + const s = String(raw ?? '').trim().toLowerCase(); + const cleaned = s.replace(/[^a-z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); + return cleaned || 'proc'; +} + export function spawnProc(label, cmd, args, env, options = {}) { const { silent = false, teeFile, teeLabel, ...spawnOptions } = options ?? {}; const child = spawn(cmd, args, { @@ -49,7 +56,18 @@ export function spawnProc(label, cmd, args, env, options = {}) { const outPrefix = `[${label}] `; const errPrefix = `[${label}] `; - const teePath = typeof teeFile === 'string' && teeFile.trim() ? teeFile.trim() : ''; + let teePath = typeof teeFile === 'string' && teeFile.trim() ? teeFile.trim() : ''; + if (!teePath) { + const teeDir = String(env?.HAPPIER_STACK_LOG_TEE_DIR ?? '').trim(); + if (teeDir) { + try { + mkdirSync(teeDir, { recursive: true }); + } catch { + // ignore + } + teePath = join(teeDir, `${sanitizeLogFileToken(label)}.log`); + } + } const teeStream = teePath ? createWriteStream(teePath, { flags: 'a' }) : null; const teeOutState = { buf: '' }; const teeErrState = { buf: '' }; diff --git a/apps/stack/scripts/utils/proc/proc.test.mjs b/apps/stack/scripts/utils/proc/proc.test.mjs index 77d9703fb..7f5c850e2 100644 --- a/apps/stack/scripts/utils/proc/proc.test.mjs +++ b/apps/stack/scripts/utils/proc/proc.test.mjs @@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { runCaptureResult } from './proc.mjs'; +import { runCaptureResult, spawnProc } from './proc.mjs'; async function withTempRoot(t) { const root = await mkdtemp(join(tmpdir(), 'happy-proc-test-')); @@ -83,3 +83,18 @@ test('runCaptureResult emits periodic keepalive logs while process is running', const raw = await readFile(teeFile, 'utf-8'); assert.match(raw, /\[keepalive-test\] still running \(elapsed \d+s, pid=\d+\)/); }); + +test('spawnProc can tee output to an env-scoped tee dir when no explicit teeFile is provided', async (t) => { + const root = await withTempRoot(t); + const teeDir = join(root, 'tee'); + const env = { ...process.env, HAPPIER_STACK_LOG_TEE_DIR: teeDir }; + + const child = spawnProc('server', process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], env, { + silent: true, + }); + await new Promise((resolve) => child.on('exit', resolve)); + + const raw = await readFile(join(teeDir, 'server.log'), 'utf-8'); + assert.match(raw, /\[server\] hello/); + assert.match(raw, /\[server\] oops/); +}); diff --git a/apps/stack/scripts/utils/server/resolve_stack_server_port.mjs b/apps/stack/scripts/utils/server/resolve_stack_server_port.mjs index 2dd71a283..f62a91ee7 100644 --- a/apps/stack/scripts/utils/server/resolve_stack_server_port.mjs +++ b/apps/stack/scripts/utils/server/resolve_stack_server_port.mjs @@ -9,6 +9,19 @@ function coercePort(v) { return Number.isFinite(n) && n > 0 ? n : null; } +function coercePositiveInt(v) { + const n = Number(String(v ?? '').trim()); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : null; +} + +function isWithinRange(port, base, range) { + const p = coercePositiveInt(port); + const b = coercePositiveInt(base); + const r = coercePositiveInt(range); + if (!p || !b || !r) return false; + return p >= b && p < b + r; +} + export async function resolveLocalServerPortForStack({ env = process.env, stackMode, @@ -21,6 +34,24 @@ export async function resolveLocalServerPortForStack({ const explicitPort = coercePort(env.HAPPIER_STACK_SERVER_PORT); if (explicitPort) { + // For non-main stacks, treat an explicit server port as a pin, but fail closed if it's + // occupied by a non-happier process. (Otherwise we can accidentally connect/auth against + // an unrelated process and get confusing behavior.) + if (inStackMode && name !== 'main') { + const url = `http://127.0.0.1:${explicitPort}`; + if (await isHappierServerRunning(url)) { + return explicitPort; + } + const free = await isTcpPortFree(explicitPort, { host: '127.0.0.1' }).catch(() => false); + if (!free) { + throw new Error( + `[stack] ${name}: pinned server port ${explicitPort} is not available (in use by another process).\n` + + `[stack] Fix: stop the process using it, or reallocate by unsetting the pin:\n` + + ` yarn env unset HAPPIER_STACK_SERVER_PORT\n` + + ` # (or) hstack env unset HAPPIER_STACK_SERVER_PORT` + ); + } + } return explicitPort; } @@ -33,7 +64,17 @@ export async function resolveLocalServerPortForStack({ // Prefer runtime state, else pick a stable per-stack port range. const runtime = runtimeStatePath ? await readStackRuntimeStateFile(runtimeStatePath) : null; const runtimePort = coercePort(runtime?.ports?.server); - if (runtimePort) { + + // If the caller configured a stable range explicitly (base/range), ignore runtime ports + // that don't fall within that range. This prevents stale low ports (e.g. 3009) from + // overriding stackless high port ranges. + const baseRaw = (env.HAPPIER_STACK_SERVER_PORT_BASE ?? '').toString().trim(); + const rangeRaw = (env.HAPPIER_STACK_SERVER_PORT_RANGE ?? '').toString().trim(); + const hasExplicitStableRange = Boolean(baseRaw || rangeRaw); + const stableBase = coercePositiveInt(baseRaw) ?? 4101; + const stableRange = coercePositiveInt(rangeRaw) ?? 1000; + + if (runtimePort && (!hasExplicitStableRange || isWithinRange(runtimePort, stableBase, stableRange))) { const url = `http://127.0.0.1:${runtimePort}`; if (await isHappierServerRunning(url)) { return runtimePort; @@ -58,4 +99,3 @@ export async function resolveLocalServerPortForStack({ return await pickNextFreeTcpPort(startPort, { host: '127.0.0.1' }); } - diff --git a/apps/stack/scripts/utils/server/resolve_stack_server_port.test.mjs b/apps/stack/scripts/utils/server/resolve_stack_server_port.test.mjs index e22c3005a..3edc8df85 100644 --- a/apps/stack/scripts/utils/server/resolve_stack_server_port.test.mjs +++ b/apps/stack/scripts/utils/server/resolve_stack_server_port.test.mjs @@ -25,6 +25,23 @@ async function listenHealthServer() { return { server, port }; } +async function listenNonHealthServer() { + const server = createServer((req, res) => { + if (req.url === '/health') { + res.statusCode = 404; + res.end('not happier'); + return; + } + res.statusCode = 200; + res.end('ok'); + }); + await new Promise((resolvePromise) => server.listen(0, '127.0.0.1', resolvePromise)); + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : null; + if (!port) throw new Error('failed to bind non-health server'); + return { server, port }; +} + test('non-main stack prefers runtime port when server is already running there', async () => { const tmp = await mkdtemp(join(tmpdir(), 'hstack-port-')); const runtimeStatePath = join(tmp, 'stack.runtime.json'); @@ -44,6 +61,45 @@ test('non-main stack prefers runtime port when server is already running there', } }); +test('non-main stack ignores runtime port when it falls outside the configured stable port range', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'hstack-port-')); + const runtimeStatePath = join(tmp, 'stack.runtime.json'); + await writeFile(runtimeStatePath, JSON.stringify({ ports: { server: 3009 } }), 'utf-8'); + + const out = await resolveLocalServerPortForStack({ + env: { + HAPPIER_STACK_SERVER_PORT_BASE: '52005', + HAPPIER_STACK_SERVER_PORT_RANGE: '2000', + }, + stackMode: true, + stackName: 'repo-test-abc', + runtimeStatePath, + defaultPort: 3005, + }); + + assert.ok(out >= 52005 && out < 52005 + 2000, `expected stable-range port, got ${out}`); + assert.notEqual(out, 3009); +}); + +test('non-main stack errors when pinned server port is occupied by a non-happier process', async () => { + const { server, port } = await listenNonHealthServer(); + try { + await assert.rejects( + () => + resolveLocalServerPortForStack({ + env: { HAPPIER_STACK_SERVER_PORT: String(port) }, + stackMode: true, + stackName: 'repo-test-abc', + runtimeStatePath: null, + defaultPort: 3005, + }), + /HAPPIER_STACK_SERVER_PORT/ + ); + } finally { + await new Promise((resolvePromise) => server.close(resolvePromise)); + } +}); + test('non-main stack picks a stable free port when no runtime port exists', async () => { const tmp = await mkdtemp(join(tmpdir(), 'hstack-port-')); const runtimeStatePath = join(tmp, 'missing.runtime.json'); diff --git a/apps/stack/scripts/utils/service/stack_autostart_resolution.mjs b/apps/stack/scripts/utils/service/stack_autostart_resolution.mjs new file mode 100644 index 000000000..67ac96f8f --- /dev/null +++ b/apps/stack/scripts/utils/service/stack_autostart_resolution.mjs @@ -0,0 +1,77 @@ +import { basename, join } from 'node:path'; + +export function resolveAutostartEnvFilePath({ + mode, + explicitEnvFilePath, + defaultMainEnvFilePath, + systemUserHomeDir, +} = {}) { + const explicit = String(explicitEnvFilePath ?? '').trim(); + if (explicit) return explicit; + + const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user'; + const home = String(systemUserHomeDir ?? '').trim(); + if (m === 'system' && home) { + return join(home, '.happier', 'stacks', 'main', 'env'); + } + + return String(defaultMainEnvFilePath ?? '').trim(); +} + +export function resolveAutostartWorkingDirectory({ + platform, + mode, + defaultHomeDir, + systemUserHomeDir, + baseDir, + installedCliRoot, +} = {}) { + const p = String(platform ?? '').trim() || process.platform; + const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user'; + + if (p === 'linux') { + if (m === 'user') return '%h'; + const home = String(systemUserHomeDir ?? '').trim() || String(defaultHomeDir ?? '').trim(); + return home || '/root'; + } + + if (p === 'darwin') { + return String(installedCliRoot ?? '').trim(); + } + + return String(baseDir ?? '').trim(); +} + +export function resolveAutostartLogPaths({ + mode, + hasStorageDirOverride, + systemUserHomeDir, + stackName, + defaultBaseDir, + defaultStdoutPath, + defaultStderrPath, +} = {}) { + const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user'; + const override = hasStorageDirOverride === true; + const home = String(systemUserHomeDir ?? '').trim(); + const name = String(stackName ?? '').trim() || 'main'; + + if (m === 'system' && !override && home) { + const baseDir = join(home, '.happier', 'stacks', name); + const logsDir = join(baseDir, 'logs'); + const stdoutFile = basename(String(defaultStdoutPath ?? '').trim() || 'happier-stack.out.log'); + const stderrFile = basename(String(defaultStderrPath ?? '').trim() || 'happier-stack.err.log'); + return { + baseDir, + stdoutPath: join(logsDir, stdoutFile), + stderrPath: join(logsDir, stderrFile), + }; + } + + return { + baseDir: String(defaultBaseDir ?? '').trim(), + stdoutPath: String(defaultStdoutPath ?? '').trim(), + stderrPath: String(defaultStderrPath ?? '').trim(), + }; +} + diff --git a/apps/stack/scripts/utils/service/stack_autostart_resolution.test.mjs b/apps/stack/scripts/utils/service/stack_autostart_resolution.test.mjs new file mode 100644 index 000000000..641f45c4b --- /dev/null +++ b/apps/stack/scripts/utils/service/stack_autostart_resolution.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + resolveAutostartEnvFilePath, + resolveAutostartLogPaths, + resolveAutostartWorkingDirectory, +} from './stack_autostart_resolution.mjs'; + +test('resolveAutostartEnvFilePath prefers explicit HAPPIER_STACK_ENV_FILE override', () => { + assert.equal( + resolveAutostartEnvFilePath({ + mode: 'system', + explicitEnvFilePath: '/custom/env', + defaultMainEnvFilePath: '/root/.happier/stacks/main/env', + systemUserHomeDir: '/home/happier', + }), + '/custom/env' + ); +}); + +test('resolveAutostartEnvFilePath uses system user home when installing a system service', () => { + assert.equal( + resolveAutostartEnvFilePath({ + mode: 'system', + explicitEnvFilePath: '', + defaultMainEnvFilePath: '/root/.happier/stacks/main/env', + systemUserHomeDir: '/home/happier', + }), + '/home/happier/.happier/stacks/main/env' + ); +}); + +test('resolveAutostartEnvFilePath falls back to default when no system user is provided', () => { + assert.equal( + resolveAutostartEnvFilePath({ + mode: 'system', + explicitEnvFilePath: '', + defaultMainEnvFilePath: '/root/.happier/stacks/main/env', + systemUserHomeDir: '', + }), + '/root/.happier/stacks/main/env' + ); +}); + +test('resolveAutostartWorkingDirectory uses %h for systemd user services', () => { + assert.equal( + resolveAutostartWorkingDirectory({ + platform: 'linux', + mode: 'user', + defaultHomeDir: '/home/me', + systemUserHomeDir: '', + baseDir: '/home/me/.happier/stacks/main', + installedCliRoot: '/opt/happier', + }), + '%h' + ); +}); + +test('resolveAutostartWorkingDirectory uses explicit home for systemd system services', () => { + assert.equal( + resolveAutostartWorkingDirectory({ + platform: 'linux', + mode: 'system', + defaultHomeDir: '/root', + systemUserHomeDir: '/home/happier', + baseDir: '/root/.happier/stacks/main', + installedCliRoot: '/opt/happier', + }), + '/home/happier' + ); +}); + +test('resolveAutostartWorkingDirectory uses explicit default home when system user home is unknown', () => { + assert.equal( + resolveAutostartWorkingDirectory({ + platform: 'linux', + mode: 'system', + defaultHomeDir: '/root', + systemUserHomeDir: '', + baseDir: '/root/.happier/stacks/main', + installedCliRoot: '/opt/happier', + }), + '/root' + ); +}); + +test('resolveAutostartLogPaths uses default paths for user mode', () => { + const res = resolveAutostartLogPaths({ + mode: 'user', + hasStorageDirOverride: false, + systemUserHomeDir: '/home/happier', + stackName: 'main', + defaultBaseDir: '/home/me/.happier/stacks/main', + defaultStdoutPath: '/home/me/.happier/stacks/main/logs/happier-stack.out.log', + defaultStderrPath: '/home/me/.happier/stacks/main/logs/happier-stack.err.log', + }); + assert.equal(res.baseDir, '/home/me/.happier/stacks/main'); + assert.equal(res.stdoutPath, '/home/me/.happier/stacks/main/logs/happier-stack.out.log'); + assert.equal(res.stderrPath, '/home/me/.happier/stacks/main/logs/happier-stack.err.log'); +}); + +test('resolveAutostartLogPaths uses system user home for system mode when storage dir is not overridden', () => { + const res = resolveAutostartLogPaths({ + mode: 'system', + hasStorageDirOverride: false, + systemUserHomeDir: '/home/happier', + stackName: 'main', + defaultBaseDir: '/root/.happier/stacks/main', + defaultStdoutPath: '/root/.happier/stacks/main/logs/happier-stack.out.log', + defaultStderrPath: '/root/.happier/stacks/main/logs/happier-stack.err.log', + }); + assert.equal(res.baseDir, '/home/happier/.happier/stacks/main'); + assert.equal(res.stdoutPath, '/home/happier/.happier/stacks/main/logs/happier-stack.out.log'); + assert.equal(res.stderrPath, '/home/happier/.happier/stacks/main/logs/happier-stack.err.log'); +}); + diff --git a/apps/stack/scripts/utils/tui/args.mjs b/apps/stack/scripts/utils/tui/args.mjs index c14562fa6..48b29a76a 100644 --- a/apps/stack/scripts/utils/tui/args.mjs +++ b/apps/stack/scripts/utils/tui/args.mjs @@ -6,7 +6,14 @@ export function isTuiHelpRequest(argv) { export function normalizeTuiForwardedArgs(argv) { if (!Array.isArray(argv) || argv.length === 0) return ['dev']; - return argv; + + // UX: `hstack tui -- --restart --mobile` is intended to mean "tui dev --restart --mobile". + // If the user only passed flags, treat them as flags for the default `dev` command. + const args = argv.filter((a) => String(a ?? '').trim() !== ''); + const allFlags = args.length > 0 && args.every((a) => String(a ?? '').trim().startsWith('-')); + if (allFlags) return ['dev', ...args]; + + return args.length ? args : ['dev']; } export function inferTuiStackName(argv, env = process.env) { diff --git a/apps/stack/scripts/utils/tui/args.test.mjs b/apps/stack/scripts/utils/tui/args.test.mjs index 96d332b26..12ec5f993 100644 --- a/apps/stack/scripts/utils/tui/args.test.mjs +++ b/apps/stack/scripts/utils/tui/args.test.mjs @@ -13,6 +13,11 @@ test('normalizeTuiForwardedArgs defaults to dev for empty args', () => { assert.deepEqual(normalizeTuiForwardedArgs([]), ['dev']); }); +test('normalizeTuiForwardedArgs defaults to dev when only flags are provided', () => { + assert.deepEqual(normalizeTuiForwardedArgs(['--restart', '--mobile']), ['dev', '--restart', '--mobile']); + assert.deepEqual(normalizeTuiForwardedArgs(['--json']), ['dev', '--json']); +}); + test('normalizeTuiForwardedArgs preserves explicit args', () => { assert.deepEqual(normalizeTuiForwardedArgs(['stack', 'dev', 'exp1']), ['stack', 'dev', 'exp1']); }); diff --git a/apps/stack/scripts/utils/tui/daemon_pane_reconcile.mjs b/apps/stack/scripts/utils/tui/daemon_pane_reconcile.mjs new file mode 100644 index 000000000..aadb7b085 --- /dev/null +++ b/apps/stack/scripts/utils/tui/daemon_pane_reconcile.mjs @@ -0,0 +1,43 @@ +import { stripAnsi } from '../ui/text.mjs'; + +function looksLikeDaemonAuthNoticeFirstLine(line) { + const first = stripAnsi(String(line ?? '')).trim().toLowerCase(); + return first === 'sign-in required' || first === 'daemon status pending' || first === 'daemon not running'; +} + +function looksLikeNoticeTitle(title) { + const t = String(title ?? '').trim(); + return ( + t === 'daemon (SIGN-IN REQUIRED)' || + t === 'daemon (WAITING FOR SERVER)' || + t === 'daemon (NOT RUNNING)' || + t === 'daemon (STARTING)' || + t === 'daemon (STARTED)' || + t === 'daemon (ALREADY RUNNING)' + ); +} + +/** + * The daemon pane is used for both log routing and auth guidance. + * + * When the daemon transitions from "sign-in required" -> running, the TUI should + * clear stale guidance so users don't think auth failed. + */ +export function reconcileDaemonPaneAfterDaemonStarts({ title, lines, daemonPid }) { + const pid = Number(daemonPid); + if (!Number.isFinite(pid) || pid <= 1) { + return { title, lines }; + } + + const nextTitle = looksLikeNoticeTitle(title) ? 'daemon (RUNNING)' : title; + const hasNoticeFirstLine = looksLikeDaemonAuthNoticeFirstLine(Array.isArray(lines) ? lines[0] : ''); + if (!hasNoticeFirstLine) { + return { title: nextTitle, lines }; + } + + return { + title: nextTitle, + lines: [`Daemon is running`, `PID: ${pid}`], + }; +} + diff --git a/apps/stack/scripts/utils/tui/daemon_pane_reconcile.test.mjs b/apps/stack/scripts/utils/tui/daemon_pane_reconcile.test.mjs new file mode 100644 index 000000000..51a17d5cc --- /dev/null +++ b/apps/stack/scripts/utils/tui/daemon_pane_reconcile.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { reconcileDaemonPaneAfterDaemonStarts } from './daemon_pane_reconcile.mjs'; + +test('reconcileDaemonPaneAfterDaemonStarts clears stale sign-in notice when daemon is running', () => { + const out = reconcileDaemonPaneAfterDaemonStarts({ + title: 'daemon (SIGN-IN REQUIRED)', + lines: ['Sign-in required', 'press "a" to run: hstack stack auth x login'], + daemonPid: 123, + }); + assert.equal(out.title, 'daemon (RUNNING)'); + assert.deepEqual(out.lines, ['Daemon is running', 'PID: 123']); +}); + +test('reconcileDaemonPaneAfterDaemonStarts preserves non-notice lines while still updating notice titles', () => { + const out = reconcileDaemonPaneAfterDaemonStarts({ + title: 'daemon (SIGN-IN REQUIRED)', + lines: ['[daemon] started'], + daemonPid: 123, + }); + assert.equal(out.title, 'daemon (RUNNING)'); + assert.deepEqual(out.lines, ['[daemon] started']); +}); + +test('reconcileDaemonPaneAfterDaemonStarts is a no-op when daemonPid is missing', () => { + const out = reconcileDaemonPaneAfterDaemonStarts({ + title: 'daemon (SIGN-IN REQUIRED)', + lines: ['Sign-in required'], + daemonPid: null, + }); + assert.equal(out.title, 'daemon (SIGN-IN REQUIRED)'); + assert.deepEqual(out.lines, ['Sign-in required']); +}); + diff --git a/apps/stack/scripts/utils/tui/script_pty_command.mjs b/apps/stack/scripts/utils/tui/script_pty_command.mjs new file mode 100644 index 000000000..db7a07c16 --- /dev/null +++ b/apps/stack/scripts/utils/tui/script_pty_command.mjs @@ -0,0 +1,27 @@ +function quoteShArg(raw) { + const s = String(raw ?? ''); + if (s === '') return "''"; + // POSIX-safe single-quote escaping: ' -> '\''. + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +function joinShCommand(argv) { + const parts = (Array.isArray(argv) ? argv : []).map((a) => quoteShArg(a)); + return parts.join(' '); +} + +export function buildScriptPtyArgs({ platform = process.platform, file = '/dev/null', command = [] } = {}) { + const cmd = 'script'; + const f = String(file ?? '').trim() || '/dev/null'; + const argv = Array.isArray(command) ? command : []; + + // util-linux `script` (common on Linux) requires -c for commands; it does not accept + // `script <file> <cmd> <args...>` like BSD `script` does. + if (platform === 'linux') { + return { cmd, args: ['-q', '-c', joinShCommand(argv), f] }; + } + + // BSD `script` (macOS) supports: script [-q] [file [command ...]] + return { cmd, args: ['-q', f, ...argv] }; +} + diff --git a/apps/stack/scripts/utils/tui/script_pty_command.test.mjs b/apps/stack/scripts/utils/tui/script_pty_command.test.mjs new file mode 100644 index 000000000..7e131bdc2 --- /dev/null +++ b/apps/stack/scripts/utils/tui/script_pty_command.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildScriptPtyArgs } from './script_pty_command.mjs'; + +test('buildScriptPtyArgs uses util-linux -c form on linux', () => { + const res = buildScriptPtyArgs({ + platform: 'linux', + file: '/dev/null', + command: ['/usr/bin/node', '/x/hstack.mjs', 'dev', '--restart'], + }); + assert.equal(res.cmd, 'script'); + assert.equal(res.args[0], '-q'); + assert.ok(res.args.includes('-c'), `expected -c in args, got ${JSON.stringify(res.args)}`); + assert.equal(res.args[res.args.length - 1], '/dev/null'); +}); + +test('buildScriptPtyArgs uses BSD args form on darwin', () => { + const res = buildScriptPtyArgs({ + platform: 'darwin', + file: '/dev/null', + command: ['/usr/bin/node', '/x/hstack.mjs', 'dev'], + }); + assert.deepEqual(res, { + cmd: 'script', + args: ['-q', '/dev/null', '/usr/bin/node', '/x/hstack.mjs', 'dev'], + }); +}); + diff --git a/apps/stack/tests/devcontainer-entrypoint-providers.test.mjs b/apps/stack/tests/dev-box-entrypoint-providers.test.mjs similarity index 83% rename from apps/stack/tests/devcontainer-entrypoint-providers.test.mjs rename to apps/stack/tests/dev-box-entrypoint-providers.test.mjs index 7840d8de9..29064b9a8 100644 --- a/apps/stack/tests/devcontainer-entrypoint-providers.test.mjs +++ b/apps/stack/tests/dev-box-entrypoint-providers.test.mjs @@ -8,8 +8,8 @@ import { fileURLToPath } from 'node:url'; const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); -test('devcontainer entrypoint installs provider CLIs via hstack when HAPPIER_PROVIDER_CLIS is set', (t) => { - const tmp = mkdtempSync(join(tmpdir(), 'happier-devcontainer-entrypoint-')); +test('dev-box entrypoint installs provider CLIs via hstack when HAPPIER_PROVIDER_CLIS is set', (t) => { + const tmp = mkdtempSync(join(tmpdir(), 'happier-dev-box-entrypoint-')); t.after(() => rmSync(tmp, { recursive: true, force: true })); const binDir = join(tmp, 'bin'); @@ -29,7 +29,7 @@ exit 0 ); chmodSync(hstackPath, 0o755); - const entrypoint = join(repoRoot, 'docker', 'devcontainer', 'entrypoint.sh'); + const entrypoint = join(repoRoot, 'docker', 'dev-box', 'entrypoint.sh'); const injectedPath = `${binDir}${delimiter}${process.env.PATH ?? ''}`; const res = spawnSync('sh', [entrypoint, 'sh', '-lc', 'echo ok'], { env: { ...process.env, PATH: injectedPath, HAPPIER_PROVIDER_CLIS: 'codex' }, @@ -44,4 +44,3 @@ exit 0 assert.match(log, /providers install/); assert.match(log, /codex/); }); - diff --git a/apps/ui/CLAUDE.md b/apps/ui/CLAUDE.md index edd33c541..20e0058f1 100644 --- a/apps/ui/CLAUDE.md +++ b/apps/ui/CLAUDE.md @@ -118,6 +118,16 @@ sources/ - **Always apply layout width constraints** from `@/components/layout` to full-screen ScrollViews and content containers for responsive design across device sizes - Always run `yarn typecheck` after all changes to ensure type safety +### Theme, Typography, and i18n (Required) + +- **No hardcoded colors**: do not introduce raw hex/rgb colors (e.g. `#000`, `#fff`) for UI styling. Use `useUnistyles()` theme tokens (`theme.colors.*`) or existing themed styles so light/dark/adaptive themes stay correct. +- **Icons must be themed**: icon `color` and background/tint props must come from theme tokens (avoid `black`/`white`). +- **Text must respect UI font scaling**: + - Prefer `@/components/ui/text/Text` and `@/components/ui/text/TextInput` over `react-native` `Text`/`TextInput`. + - Avoid hardcoded font sizes in new UI code. If you must set a base size, ensure it scales via `uiFontScale` (and stacks with OS Dynamic Type on native). + - For embedded editors, use `resolveCodeEditorFontMetrics(...)` and propagate scale to Monaco/CodeMirror surfaces. +- **All user-facing copy must be translated**: use `t('...')` for UI strings, add keys to all supported locale files under `sources/text/translations/`, and avoid hardcoding English in components. + ## Folder Structure & Naming Conventions (2026-01) These conventions are **additive** to the guidelines above. The goal is to keep screens and sync logic easy to reason about. @@ -191,6 +201,8 @@ On native, stacking a React Navigation / Expo Router modal screen with an RN `<M Use the app `Popover` + `FloatingOverlay` for menus/tooltips/context menus. - Use `portal={{ web: { target: 'body' }, native: true }}` when the anchor is inside overflow-clipped containers (headers, lists, scrollviews). +- For settings-style lists, prefer `ItemList` as the popover boundary (it provides a `PopoverBoundaryProvider` for the screen ScrollView). Avoid binding popover boundaries to `ItemGroup` containers, which can incorrectly clamp dropdown sizing/placement. +- When a popover must be constrained to a scroll container, pass the **scroll container ref** as the boundary (`DropdownMenu popoverBoundaryRef=...` / `Popover boundaryRef=...`). Do not use a nested non-scroll wrapper `View` ref unless you intentionally want viewport-wide bounds and have validated scroll alignment on web. - When the backdrop is enabled (default), `onRequestClose` is required (Popover is controlled). - For context-menu style overlays, prefer `backdrop={{ effect: 'blur', anchorOverlay: ..., closeOnPan: true }}` so the trigger stays crisp above the blur without cutout seams. - On web, portaled popovers are wrapped in Radix `DismissableLayer.Branch` (via `radixCjs.ts`) so Expo Router/Vaul/Radix layers don’t treat them as “outside”. @@ -380,16 +392,17 @@ Always use `StyleSheet.create` from 'react-native-unistyles': ```typescript import { StyleSheet } from 'react-native-unistyles' +import { Text } from '@/components/ui/text/Text' const styles = StyleSheet.create((theme, runtime) => ({ container: { flex: 1, - backgroundColor: theme.colors.background, + backgroundColor: theme.colors.groupped.background, paddingTop: runtime.insets.top, paddingHorizontal: theme.margins.md, }, text: { - color: theme.colors.typography, + color: theme.colors.text, fontSize: 16, } })) @@ -401,17 +414,18 @@ For React Native components, provide styles directly: ```typescript import React from 'react' -import { View, Text } from 'react-native' +import { View } from 'react-native' import { StyleSheet } from 'react-native-unistyles' +import { Text } from '@/components/ui/text/Text' const styles = StyleSheet.create((theme, runtime) => ({ container: { flex: 1, - backgroundColor: theme.colors.background, + backgroundColor: theme.colors.groupped.background, paddingTop: runtime.insets.top, }, text: { - color: theme.colors.typography, + color: theme.colors.text, fontSize: 16, } })) @@ -522,6 +536,12 @@ const MyComponent = () => { } ``` +## Typography and Font Size + +- Use `Text` / `TextInput` from `@/components/ui/text/Text` (do not import `Text` / `TextInput` from `react-native` in app UI code). +- The app supports a user-selectable in-app font size (`localSettings.uiFontSize`), which scales `fontSize`, `lineHeight`, and `letterSpacing` on these primitives and **stacks with OS Dynamic Type**. +- It’s OK to use numeric base `fontSize`/`lineHeight` in styles, but they must be rendered via the app text primitives so they scale correctly. + ### Special Component Considerations #### Expo Image @@ -536,7 +556,7 @@ import { StyleSheet, useStyles } from 'react-native-unistyles' const styles = StyleSheet.create((theme) => ({ image: { borderRadius: 8, - backgroundColor: theme.colors.background, // Other styles use theme + backgroundColor: theme.colors.surface, // Other styles use theme } })) @@ -546,7 +566,7 @@ const MyComponent = () => { return ( <Image style={[{ width: 100, height: 100 }, styles.image]} // Size as inline styles - tintColor={theme.colors.primary} // tintColor goes on component + tintColor={theme.colors.textLink} // tintColor goes on component source={{ uri: 'https://example.com/image.jpg' }} /> ) diff --git a/apps/ui/eas.json b/apps/ui/eas.json index 2b5b290a5..a93da4025 100644 --- a/apps/ui/eas.json +++ b/apps/ui/eas.json @@ -8,6 +8,7 @@ "node": "22.14.0", "env": { "HAPPIER_INSTALL_SCOPE": "ui,protocol,agents", + "EXPO_PUBLIC_HAPPY_SERVER_URL": "https://api.happier.dev", "HAPPIER_UI_VENDOR_WEB_ASSETS": "0" }, "android": { diff --git a/apps/ui/metro.config.js b/apps/ui/metro.config.js index f7d0e04a7..370350b63 100644 --- a/apps/ui/metro.config.js +++ b/apps/ui/metro.config.js @@ -46,21 +46,27 @@ const fontFaceObserverWebShim = path.resolve(__dirname, "sources/platform/shims/ const defaultResolveRequest = config.resolver.resolveRequest; config.resolver.resolveRequest = (context, moduleName, platform) => { + // Fix event-target-shim/index import - exports define "." not "./index" + let resolvedModuleName = moduleName; + if (moduleName === "event-target-shim/index") { + resolvedModuleName = "event-target-shim"; + } + if ( platform === "web" && - (moduleName === "@huggingface/transformers" || - moduleName.startsWith("@huggingface/transformers/")) + (resolvedModuleName === "@huggingface/transformers" || + resolvedModuleName.startsWith("@huggingface/transformers/")) ) { return { type: "sourceFile", filePath: transformersStub }; } // expo-font uses fontfaceobserver on web with a hard-coded timeout; in practice this can // surface as unhandled errors. Use a web-safe shim that avoids throwing on timeouts. - if (platform === "web" && moduleName === "fontfaceobserver") { + if (platform === "web" && resolvedModuleName === "fontfaceobserver") { return { type: "sourceFile", filePath: fontFaceObserverWebShim }; } - if (moduleName === "kokoro-js" || moduleName.startsWith("kokoro-js/")) { + if (resolvedModuleName === "kokoro-js" || resolvedModuleName.startsWith("kokoro-js/")) { if (platform === "web") { return { type: "sourceFile", filePath: kokoroJsStub }; } diff --git a/apps/ui/package.json b/apps/ui/package.json index eb9da7a0d..cf327cfce 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -42,7 +42,7 @@ "preset": "jest-expo" }, "dependencies": { - "@config-plugins/react-native-webrtc": "^12.0.0", + "@config-plugins/react-native-webrtc": "^13.0.0", "@elevenlabs/react": "^0.12.3", "@elevenlabs/react-native": "^0.5.7", "@expo/vector-icons": "^15.0.2", diff --git a/apps/ui/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts b/apps/ui/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts index c4aab7876..7a5d6883c 100644 --- a/apps/ui/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts +++ b/apps/ui/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts @@ -132,6 +132,11 @@ vi.mock('@/sync/domains/state/storage', () => { React.useMemo(() => 0, [name]); return [name === 'codexMcpResumeInstallSpec' ? '' : null, vi.fn()]; }, + useLocalSetting: (name: string) => { + React.useMemo(() => 0, [name]); + if (name === 'uiFontScale') return 1; + return null; + }, }; }); diff --git a/apps/ui/sources/__tests__/app/machine/machineDetails.executionRuns.test.tsx b/apps/ui/sources/__tests__/app/machine/machineDetails.executionRuns.test.tsx index 66bfb4c71..437f13a32 100644 --- a/apps/ui/sources/__tests__/app/machine/machineDetails.executionRuns.test.tsx +++ b/apps/ui/sources/__tests__/app/machine/machineDetails.executionRuns.test.tsx @@ -45,28 +45,6 @@ vi.mock('expo-router', () => { }; }); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - header: { tint: '#000' }, - input: { background: '#fff', text: '#000' }, - groupped: { background: '#fff', sectionTitle: '#000' }, - divider: '#ddd', - button: { primary: { background: '#000', tint: '#fff' } }, - text: '#000', - textSecondary: '#666', - surface: '#fff', - surfaceHigh: '#fff', - shadow: { color: '#000', opacity: 0.1 }, - status: { error: '#f00', connected: '#0f0', connecting: '#ff0', disconnected: '#999', default: '#999' }, - permissionButton: { inactive: { background: '#ccc' } }, - } - } - }), - StyleSheet: { create: (fn: any) => fn({ colors: { input: { background: '#fff', text: '#000' }, groupped: { background: '#fff', sectionTitle: '#000' }, divider: '#ddd', button: { primary: { background: '#000', tint: '#fff' } }, text: '#000', textSecondary: '#666', surface: '#fff', surfaceHigh: '#fff', shadow: { color: '#000', opacity: 0.1 }, status: { error: '#f00', connected: '#0f0', connecting: '#ff0', disconnected: '#999', default: '#999' }, permissionButton: { inactive: { background: '#ccc' } }, header: { tint: '#000' } } }) }, -})); - vi.mock('@/constants/Typography', () => ({ Typography: { default: () => ({}) } })); vi.mock('@/text', () => ({ t: (key: string) => key })); @@ -160,7 +138,7 @@ vi.mock('@/utils/sessions/sessionUtils', () => ({ formatPathRelativeToHome: () = vi.mock('@/utils/path/pathUtils', () => ({ resolveAbsolutePath: () => '' })); vi.mock('@/sync/domains/settings/terminalSettings', () => ({ resolveTerminalSpawnOptions: () => ({}) })); vi.mock('@/sync/domains/session/spawn/windowsRemoteSessionConsole', () => ({ resolveWindowsRemoteSessionConsoleFromMachineMetadata: () => 'visible' })); -vi.mock('@/capabilities/installableDepsRegistry', () => ({ getInstallableDepRegistryEntries: () => [] })); +vi.mock('@/capabilities/installablesRegistry', () => ({ getInstallablesRegistryEntries: () => [] })); vi.mock('@/sync/domains/server/activeServerSwitch', () => ({ setActiveServerAndSwitch: vi.fn(async () => true), })); @@ -213,6 +191,28 @@ describe('MachineDetailScreen (execution runs section)', () => { expect(itemGroupSpy).toHaveBeenCalledWith(expect.objectContaining({ title: 'runs.title' })); }); + it('includes an Installables navigation item', async () => { + itemSpy.mockClear(); + routerMock.push.mockClear(); + const { default: MachineDetailScreen } = await import('@/app/(app)/machine/[id]'); + + await act(async () => { + renderer.create(React.createElement(MachineDetailScreen)); + await Promise.resolve(); + }); + + const installablesItem = itemSpy.mock.calls + .map((c) => c[0]) + .find((p) => p?.title === 'Installables'); + expect(installablesItem).toBeTruthy(); + + await act(async () => { + installablesItem.onPress?.(); + }); + + expect(routerMock.push).toHaveBeenCalled(); + }); + it('shows only running runs by default and includes finished when toggled', async () => { machineExecutionRunsListSpy.mockResolvedValueOnce({ ok: true, diff --git a/apps/ui/sources/__tests__/app/machine/machineDetails.revokeMachine.test.tsx b/apps/ui/sources/__tests__/app/machine/machineDetails.revokeMachine.test.tsx index c041133da..064cfc1b0 100644 --- a/apps/ui/sources/__tests__/app/machine/machineDetails.revokeMachine.test.tsx +++ b/apps/ui/sources/__tests__/app/machine/machineDetails.revokeMachine.test.tsx @@ -9,7 +9,7 @@ const itemSpy = vi.fn(); const routerMock = { back: vi.fn(), push: vi.fn(), replace: vi.fn() }; const confirmSpy = vi.fn<(..._args: any[]) => Promise<boolean>>(async () => true); const refreshMachinesThrottledSpy = vi.fn(async () => {}); -const revokeSpy = vi.fn(async () => ({ ok: true as const })); +const revokeSpy = vi.fn(async (_machineId: string) => ({ ok: true as const })); vi.mock('react-native-reanimated', () => ({})); @@ -61,7 +61,33 @@ vi.mock('react-native-unistyles', () => ({ } } }), - StyleSheet: { create: (fn: any) => fn({ colors: { header: { tint: '#000' }, input: { background: '#fff', text: '#000' }, groupped: { background: '#fff', sectionTitle: '#000' }, divider: '#ddd', button: { primary: { background: '#000', tint: '#fff' } }, text: '#000', textSecondary: '#666', surface: '#fff', surfaceHigh: '#fff', shadow: { color: '#000', opacity: 0.1 }, status: { error: '#f00', connected: '#0f0', connecting: '#ff0', disconnected: '#999', default: '#999' }, permissionButton: { inactive: { background: '#ccc' } } } }) }, + StyleSheet: { + create: (input: any) => + typeof input === 'function' + ? input({ + colors: { + header: { tint: '#000' }, + input: { background: '#fff', text: '#000' }, + groupped: { background: '#fff', sectionTitle: '#000' }, + divider: '#ddd', + button: { primary: { background: '#000', tint: '#fff' } }, + text: '#000', + textSecondary: '#666', + surface: '#fff', + surfaceHigh: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + status: { + error: '#f00', + connected: '#0f0', + connecting: '#ff0', + disconnected: '#999', + default: '#999', + }, + permissionButton: { inactive: { background: '#ccc' } }, + }, + }) + : input, + }, })); vi.mock('@/constants/Typography', () => ({ Typography: { default: () => ({}) } })); @@ -82,7 +108,7 @@ vi.mock('@/components/ui/forms/Switch', () => ({ Switch: () => null })); vi.mock('@/components/machines/InstallableDepInstaller', () => ({ InstallableDepInstaller: () => null })); vi.mock('@/components/sessions/runs/ExecutionRunRow', () => ({ ExecutionRunRow: () => null })); -vi.mock('@/modal', () => ({ Modal: { alert: vi.fn(), confirm: (...args: any[]) => confirmSpy(...args), prompt: vi.fn(), show: vi.fn() } })); +vi.mock('@/modal', () => ({ Modal: { alert: vi.fn(), confirm: confirmSpy, prompt: vi.fn(), show: vi.fn() } })); vi.mock('@/sync/ops', () => ({ machineSpawnNewSession: vi.fn(async () => ({ type: 'error', errorCode: 'unexpected', errorMessage: 'noop' })), @@ -90,7 +116,7 @@ vi.mock('@/sync/ops', () => ({ machineStopSession: vi.fn(async () => ({ ok: true })), machineUpdateMetadata: vi.fn(async () => ({})), machineExecutionRunsList: vi.fn(async () => ({ ok: true, runs: [] })), - machineRevokeFromAccount: (...args: any[]) => revokeSpy(...args), + machineRevokeFromAccount: revokeSpy, })); vi.mock('@/sync/ops/sessionExecutionRuns', () => ({ @@ -133,13 +159,13 @@ vi.mock('@/hooks/session/useNavigateToSession', () => ({ useNavigateToSession: ( vi.mock('@/hooks/server/useMachineCapabilitiesCache', () => ({ useMachineCapabilitiesCache: () => ({ state: { status: 'idle' }, refresh: vi.fn() }) })); vi.mock('@/sync/domains/server/serverProfiles', () => ({ getActiveServerId: () => 'server-a' })); vi.mock('@/sync/domains/server/activeServerSwitch', () => ({ setActiveServerAndSwitch: vi.fn(async () => true) })); -vi.mock('@/sync/sync', () => ({ sync: { refreshMachinesThrottled: (...args: any[]) => refreshMachinesThrottledSpy(...args), refreshMachines: vi.fn(), retryNow: vi.fn() } })); +vi.mock('@/sync/sync', () => ({ sync: { refreshMachinesThrottled: refreshMachinesThrottledSpy, refreshMachines: vi.fn(), retryNow: vi.fn() } })); vi.mock('@/utils/sessions/machineUtils', () => ({ isMachineOnline: () => true })); vi.mock('@/utils/sessions/sessionUtils', () => ({ formatPathRelativeToHome: () => '', getSessionName: () => '', getSessionSubtitle: () => '' })); vi.mock('@/utils/path/pathUtils', () => ({ resolveAbsolutePath: () => '' })); vi.mock('@/sync/domains/settings/terminalSettings', () => ({ resolveTerminalSpawnOptions: () => ({}) })); vi.mock('@/sync/domains/session/spawn/windowsRemoteSessionConsole', () => ({ resolveWindowsRemoteSessionConsoleFromMachineMetadata: () => 'visible' })); -vi.mock('@/capabilities/installableDepsRegistry', () => ({ getInstallableDepRegistryEntries: () => [] })); +vi.mock('@/capabilities/installablesRegistry', () => ({ getInstallablesRegistryEntries: () => [] })); describe('MachineDetailScreen (revoke/forget machine)', () => { beforeEach(() => { @@ -176,4 +202,3 @@ describe('MachineDetailScreen (revoke/forget machine)', () => { expect(routerMock.back).toHaveBeenCalled(); }); }); - diff --git a/apps/ui/sources/__tests__/app/machine/machineDetails.serverIdSwitch.test.tsx b/apps/ui/sources/__tests__/app/machine/machineDetails.serverIdSwitch.test.tsx index 257c2ec88..e3649ba04 100644 --- a/apps/ui/sources/__tests__/app/machine/machineDetails.serverIdSwitch.test.tsx +++ b/apps/ui/sources/__tests__/app/machine/machineDetails.serverIdSwitch.test.tsx @@ -41,7 +41,33 @@ vi.mock('expo-router', () => { vi.mock('react-native-unistyles', () => ({ useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' }, input: { background: '#fff', text: '#000' }, groupped: { background: '#fff', sectionTitle: '#000' }, divider: '#ddd', button: { primary: { background: '#000', tint: '#fff' } }, text: '#000', textSecondary: '#666', surface: '#fff', surfaceHigh: '#fff', shadow: { color: '#000', opacity: 0.1 }, status: { error: '#f00', connected: '#0f0', connecting: '#ff0', disconnected: '#999', default: '#999' }, permissionButton: { inactive: { background: '#ccc' } } } } }), - StyleSheet: { create: (fn: any) => fn({ colors: { header: { tint: '#000' }, input: { background: '#fff', text: '#000' }, groupped: { background: '#fff', sectionTitle: '#000' }, divider: '#ddd', button: { primary: { background: '#000', tint: '#fff' } }, text: '#000', textSecondary: '#666', surface: '#fff', surfaceHigh: '#fff', shadow: { color: '#000', opacity: 0.1 }, status: { error: '#f00', connected: '#0f0', connecting: '#ff0', disconnected: '#999', default: '#999' }, permissionButton: { inactive: { background: '#ccc' } } } }) }, + StyleSheet: { + create: (input: any) => + typeof input === 'function' + ? input({ + colors: { + header: { tint: '#000' }, + input: { background: '#fff', text: '#000' }, + groupped: { background: '#fff', sectionTitle: '#000' }, + divider: '#ddd', + button: { primary: { background: '#000', tint: '#fff' } }, + text: '#000', + textSecondary: '#666', + surface: '#fff', + surfaceHigh: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + status: { + error: '#f00', + connected: '#0f0', + connecting: '#ff0', + disconnected: '#999', + default: '#999', + }, + permissionButton: { inactive: { background: '#ccc' } }, + }, + }) + : input, + }, })); vi.mock('@/constants/Typography', () => ({ Typography: { default: () => ({}) } })); @@ -101,7 +127,7 @@ vi.mock('@/utils/sessions/sessionUtils', () => ({ formatPathRelativeToHome: () = vi.mock('@/utils/path/pathUtils', () => ({ resolveAbsolutePath: () => '' })); vi.mock('@/sync/domains/settings/terminalSettings', () => ({ resolveTerminalSpawnOptions: () => ({}) })); vi.mock('@/sync/domains/session/spawn/windowsRemoteSessionConsole', () => ({ resolveWindowsRemoteSessionConsoleFromMachineMetadata: () => 'visible' })); -vi.mock('@/capabilities/installableDepsRegistry', () => ({ getInstallableDepRegistryEntries: () => [] })); +vi.mock('@/capabilities/installablesRegistry', () => ({ getInstallablesRegistryEntries: () => [] })); describe('MachineDetailScreen (serverId param switching)', () => { it('switches active server when serverId param is provided and differs from current active server', async () => { diff --git a/apps/ui/sources/__tests__/app/new/pick/path.presentation.test.ts b/apps/ui/sources/__tests__/app/new/pick/path.presentation.test.ts index 27f286dd6..6f923c620 100644 --- a/apps/ui/sources/__tests__/app/new/pick/path.presentation.test.ts +++ b/apps/ui/sources/__tests__/app/new/pick/path.presentation.test.ts @@ -26,6 +26,7 @@ vi.mock('react-native', () => ({ Text: 'Text', Pressable: 'Pressable', Platform: { OS: 'ios', select: <T,>(options: PlatformSelectOptions<T>) => options.ios ?? options.default }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, TurboModuleRegistry: { getEnforcing: () => ({}) }, })); @@ -47,10 +48,14 @@ vi.mock('@react-navigation/native', () => ({ }, })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ theme: { colors: PICKER_THEME_COLORS } }), - StyleSheet: { create: (fn: any) => fn({ colors: PICKER_THEME_COLORS }) }, -})); +vi.mock('react-native-unistyles', () => { + const colors = { ...PICKER_THEME_COLORS, shadow: { color: '#000', opacity: 0.2 } }; + const theme = { colors }; + return { + useUnistyles: () => ({ theme }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input(theme) : input) }, + }; +}); vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', diff --git a/apps/ui/sources/__tests__/app/new/pick/path.stackOptionsStability.test.ts b/apps/ui/sources/__tests__/app/new/pick/path.stackOptionsStability.test.ts index a08d8cdbf..01aba87cf 100644 --- a/apps/ui/sources/__tests__/app/new/pick/path.stackOptionsStability.test.ts +++ b/apps/ui/sources/__tests__/app/new/pick/path.stackOptionsStability.test.ts @@ -61,7 +61,8 @@ vi.mock('@/utils/sessions/recentPaths', () => ({ })); vi.mock('react-native', () => ({ - Platform: { OS: 'ios' }, + Platform: { OS: 'ios', select: (options: any) => options?.ios ?? options?.default ?? options?.web ?? null }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, View: 'View', Text: 'Text', Pressable: 'Pressable', @@ -72,8 +73,8 @@ vi.mock('@expo/vector-icons', () => ({ })); vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ theme: { colors: PICKER_THEME_COLORS } }), - StyleSheet: { create: () => ({}) }, + useUnistyles: () => ({ theme: { colors: { ...PICKER_THEME_COLORS, shadow: { color: '#000', opacity: 0.2 } } } }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input({ colors: { ...PICKER_THEME_COLORS, shadow: { color: '#000', opacity: 0.2 } } }) : input) }, })); vi.mock('@react-navigation/native', () => ({ diff --git a/apps/ui/sources/__tests__/app/new/pick/path.test.ts b/apps/ui/sources/__tests__/app/new/pick/path.test.ts index dbeb82d34..901cd5cfc 100644 --- a/apps/ui/sources/__tests__/app/new/pick/path.test.ts +++ b/apps/ui/sources/__tests__/app/new/pick/path.test.ts @@ -41,6 +41,7 @@ vi.mock('react-native', () => ({ OS: 'web', select: (options: { web?: unknown; ios?: unknown; default?: unknown }) => options.web ?? options.ios ?? options.default, }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, TurboModuleRegistry: { getEnforcing: () => ({}), }, @@ -63,10 +64,13 @@ vi.mock('@react-navigation/native', () => ({ }, })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ theme: { colors: PICKER_THEME_COLORS } }), - StyleSheet: { create: (fn: any) => fn({ colors: PICKER_THEME_COLORS }) }, -})); +vi.mock('react-native-unistyles', () => { + const colors = { ...PICKER_THEME_COLORS, shadow: { color: '#000', opacity: 0.2 } }; + return { + useUnistyles: () => ({ theme: { colors } }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input({ colors }) : input) }, + }; +}); vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', diff --git a/apps/ui/sources/__tests__/app/share/publicShareViewer.plaintext.test.tsx b/apps/ui/sources/__tests__/app/share/publicShareViewer.plaintext.test.tsx new file mode 100644 index 000000000..14a0dca14 --- /dev/null +++ b/apps/ui/sources/__tests__/app/share/publicShareViewer.plaintext.test.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +(globalThis as any).expo = { EventEmitter: class { } }; + +const serverFetchSpy = vi.fn(); +const decryptDataKeyFromPublicShareSpy = vi.fn(); +const transcriptListSpy = vi.fn(); + +vi.mock('react-native-reanimated', () => ({})); + +vi.mock('react-native', () => { + type PlatformSelectOptions<T> = { web?: T; default?: T }; + return { + Platform: { OS: 'web', select: <T,>(options: PlatformSelectOptions<T>) => options.web ?? options.default }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, + Dimensions: { + get: () => ({ width: 800, height: 600, scale: 1, fontScale: 1 }), + }, + TurboModuleRegistry: { getEnforcing: () => ({}) }, + View: 'View', + Text: 'Text', + ActivityIndicator: 'ActivityIndicator', + }; +}); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +const routerMock = { back: vi.fn(), push: vi.fn(), replace: vi.fn() }; +vi.mock('expo-router', () => { + const Stack: { Screen: () => null } = { Screen: () => null }; + return { + Stack, + useLocalSearchParams: () => ({ token: 'tok-1' }), + useRouter: () => routerMock, + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + surface: '#fff', + textLink: '#00f', + groupped: { background: '#fff', sectionTitle: '#000' }, + textDestructive: '#f00', + text: '#000', + textSecondary: '#666', + header: { tint: '#000' }, + divider: '#ddd', + surfaceHigh: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + status: { error: '#f00' }, + button: { primary: { background: '#000', tint: '#fff' } }, + input: { background: '#fff', text: '#000' }, + permissionButton: { inactive: { background: '#ccc' } }, + }, + }, + }), + StyleSheet: { + create: (arg: any) => { + if (typeof arg === 'function') { + const theme = { + colors: { + surface: '#fff', + textLink: '#00f', + groupped: { background: '#fff', sectionTitle: '#000' }, + textDestructive: '#f00', + text: '#000', + textSecondary: '#666', + header: { tint: '#000' }, + divider: '#ddd', + surfaceHigh: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + status: { error: '#f00' }, + button: { primary: { background: '#000', tint: '#fff' } }, + input: { background: '#fff', text: '#000' }, + permissionButton: { inactive: { background: '#ccc' } }, + }, + }; + // Support both `StyleSheet.create((theme) => ...)` and `StyleSheet.create(({ theme }) => ...)`. + return arg({ ...theme, theme, colors: theme.colors }); + } + return arg; + }, + }, +})); + +vi.mock('@/text', () => ({ t: (key: string) => key })); + +vi.mock('@/sync/http/client', () => ({ + serverFetch: serverFetchSpy, +})); + +vi.mock('@/sync/encryption/publicShareEncryption', () => ({ + decryptDataKeyFromPublicShare: decryptDataKeyFromPublicShareSpy, +})); + +vi.mock('@/auth/context/AuthContext', () => ({ + useAuth: () => ({ credentials: { token: 'auth-token' } }), +})); + +vi.mock('@/components/sessions/transcript/ChatHeaderView', () => ({ + ChatHeaderView: () => null, +})); + +vi.mock('@/components/sessions/transcript/TranscriptList', () => ({ + TranscriptList: (props: any) => { + transcriptListSpy(props); + return null; + }, +})); + +describe('PublicShareViewerScreen (plaintext)', () => { + it('does not attempt DEK decryption for plaintext sessions', async () => { + serverFetchSpy + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + session: { + id: 's1', + seq: 1, + encryptionMode: 'plain', + createdAt: 1, + updatedAt: 2, + active: true, + activeAt: 2, + metadata: JSON.stringify({ path: '/repo', host: 'devbox', name: 'Plain Session' }), + metadataVersion: 1, + agentState: JSON.stringify({}), + agentStateVersion: 1, + }, + owner: { id: 'u1', username: 'alice', firstName: null, lastName: null, avatar: null }, + accessLevel: 'view', + encryptedDataKey: null, + isConsentRequired: false, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + messages: [ + { + id: 'm1', + seq: 1, + localId: null, + content: { + t: 'plain', + v: { role: 'user', content: { type: 'text', text: 'hello' } }, + }, + createdAt: 3, + updatedAt: 3, + }, + ], + }), + }); + + const { default: PublicShareViewerScreen } = await import('@/app/(app)/share/[token]'); + + await act(async () => { + renderer.create(<PublicShareViewerScreen />); + }); + + // Allow async effect to resolve. + await act(async () => { + await Promise.resolve(); + }); + + expect(decryptDataKeyFromPublicShareSpy).not.toHaveBeenCalled(); + expect(serverFetchSpy).toHaveBeenCalledWith( + '/v1/public-share/tok-1', + expect.anything(), + expect.objectContaining({ includeAuth: false }), + ); + expect(serverFetchSpy).toHaveBeenCalledWith( + '/v1/public-share/tok-1/messages', + expect.anything(), + expect.objectContaining({ includeAuth: false }), + ); + expect(transcriptListSpy).toHaveBeenCalled(); + }); +}); diff --git a/apps/ui/sources/__tests__/install/ensureNohoistPeerLinks.test.ts b/apps/ui/sources/__tests__/install/ensureNohoistPeerLinks.test.ts new file mode 100644 index 000000000..9ea588264 --- /dev/null +++ b/apps/ui/sources/__tests__/install/ensureNohoistPeerLinks.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +function makeTempDir(prefix: string) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +describe('ensureNohoistPeerLinks', () => { + it('links nohoisted react-native into repo root node_modules for tooling that expects it', async () => { + const mod: any = await import('../../../tools/ensureNohoistPeerLinks.mjs'); + expect(typeof mod.ensureNohoistPeerLinks).toBe('function'); + + const tmp = makeTempDir('happier-ui-nohoist-links-'); + const repoRootDir = path.join(tmp, 'repo'); + const expoAppDir = path.join(repoRootDir, 'apps', 'ui'); + + try { + fs.mkdirSync(path.join(repoRootDir, 'node_modules'), { recursive: true }); + fs.mkdirSync(path.join(expoAppDir, 'node_modules', 'react-native'), { recursive: true }); + fs.writeFileSync(path.join(expoAppDir, 'node_modules', 'react-native', 'package.json'), '{}\n', 'utf8'); + + mod.ensureNohoistPeerLinks({ repoRootDir, expoAppDir }); + + const linkPath = path.join(repoRootDir, 'node_modules', 'react-native'); + const targetPath = path.join(expoAppDir, 'node_modules', 'react-native'); + + expect(fs.existsSync(path.join(linkPath, 'package.json'))).toBe(true); + expect(fs.realpathSync(linkPath)).toBe(fs.realpathSync(targetPath)); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + diff --git a/apps/ui/sources/agents/catalog/enabled.test.ts b/apps/ui/sources/agents/catalog/enabled.test.ts index 94eff8abd..1ea2b40be 100644 --- a/apps/ui/sources/agents/catalog/enabled.test.ts +++ b/apps/ui/sources/agents/catalog/enabled.test.ts @@ -4,7 +4,7 @@ import { getEnabledAgentIds, isAgentEnabled } from './enabled'; describe('agents/enabled', () => { it('enables all agents by default when no explicit backend map is provided', () => { - const allAgents = ['claude', 'codex', 'opencode', 'gemini', 'auggie', 'qwen', 'kimi', 'kilo', 'pi'] as const; + const allAgents = ['claude', 'codex', 'opencode', 'gemini', 'auggie', 'qwen', 'kimi', 'kilo', 'pi', 'copilot'] as const; for (const agentId of allAgents) { expect(isAgentEnabled({ agentId, backendEnabledById: {} })).toBe(true); expect(isAgentEnabled({ agentId, backendEnabledById: null })).toBe(true); @@ -30,11 +30,11 @@ describe('agents/enabled', () => { }); it('returns enabled agent ids in display order', () => { - expect(getEnabledAgentIds({ backendEnabledById: {} })).toEqual(['claude', 'codex', 'opencode', 'gemini', 'auggie', 'qwen', 'kimi', 'kilo', 'pi']); - expect(getEnabledAgentIds({ backendEnabledById: { gemini: false, auggie: false } })).toEqual(['claude', 'codex', 'opencode', 'qwen', 'kimi', 'kilo', 'pi']); + expect(getEnabledAgentIds({ backendEnabledById: {} })).toEqual(['claude', 'codex', 'opencode', 'gemini', 'auggie', 'qwen', 'kimi', 'kilo', 'pi', 'copilot']); + expect(getEnabledAgentIds({ backendEnabledById: { gemini: false, auggie: false } })).toEqual(['claude', 'codex', 'opencode', 'qwen', 'kimi', 'kilo', 'pi', 'copilot']); }); it('ignores unknown backend ids in the toggle map', () => { - expect(getEnabledAgentIds({ backendEnabledById: { unknownAgent: false } })).toEqual(['claude', 'codex', 'opencode', 'gemini', 'auggie', 'qwen', 'kimi', 'kilo', 'pi']); + expect(getEnabledAgentIds({ backendEnabledById: { unknownAgent: false } })).toEqual(['claude', 'codex', 'opencode', 'gemini', 'auggie', 'qwen', 'kimi', 'kilo', 'pi', 'copilot']); }); }); diff --git a/apps/ui/sources/agents/providers/_registry/providerSettingsRegistry.ts b/apps/ui/sources/agents/providers/_registry/providerSettingsRegistry.ts index ec8ddcb8b..bac3effaf 100644 --- a/apps/ui/sources/agents/providers/_registry/providerSettingsRegistry.ts +++ b/apps/ui/sources/agents/providers/_registry/providerSettingsRegistry.ts @@ -10,6 +10,7 @@ import { KIMI_PROVIDER_SETTINGS_PLUGIN } from '../kimi/settings/plugin'; import { OPENCODE_PROVIDER_SETTINGS_PLUGIN } from '../opencode/settings/plugin'; import { PI_PROVIDER_SETTINGS_PLUGIN } from '../pi/settings/plugin'; import { QWEN_PROVIDER_SETTINGS_PLUGIN } from '../qwen/settings/plugin'; +import { COPILOT_PROVIDER_SETTINGS_PLUGIN } from '../copilot/settings/plugin'; export function assertProviderSettingsPluginsValid(plugins: readonly ProviderSettingsPlugin[]): void { const errors: string[] = []; @@ -85,6 +86,7 @@ export const PROVIDER_SETTINGS_PLUGINS: readonly ProviderSettingsPlugin[] = [ KIMI_PROVIDER_SETTINGS_PLUGIN, KILO_PROVIDER_SETTINGS_PLUGIN, PI_PROVIDER_SETTINGS_PLUGIN, + COPILOT_PROVIDER_SETTINGS_PLUGIN, ]; assertProviderSettingsPluginsValid(PROVIDER_SETTINGS_PLUGINS); diff --git a/apps/ui/sources/agents/providers/auggie/AuggieIndexingChip.tsx b/apps/ui/sources/agents/providers/auggie/AuggieIndexingChip.tsx index f451e9b0f..c1a498fc0 100644 --- a/apps/ui/sources/agents/providers/auggie/AuggieIndexingChip.tsx +++ b/apps/ui/sources/agents/providers/auggie/AuggieIndexingChip.tsx @@ -1,10 +1,12 @@ import { Octicons } from '@expo/vector-icons'; import * as React from 'react'; -import { Pressable, Text } from 'react-native'; +import { Pressable } from 'react-native'; import { hapticsLight } from '@/components/ui/theme/haptics'; import type { AgentInputExtraActionChip } from '@/components/sessions/agentInput'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + export function createAuggieAllowIndexingChip(opts: Readonly<{ allowIndexing: boolean; diff --git a/apps/ui/sources/agents/providers/codex/uiBehavior.ts b/apps/ui/sources/agents/providers/codex/uiBehavior.ts index 8782f2b6b..cc920ea21 100644 --- a/apps/ui/sources/agents/providers/codex/uiBehavior.ts +++ b/apps/ui/sources/agents/providers/codex/uiBehavior.ts @@ -3,6 +3,7 @@ import type { ResumeCapabilityOptions } from '@/agents/runtime/resumeCapabilitie import { getCodexAcpDepData } from '@/capabilities/codexAcpDep'; import { getCodexMcpResumeDepData } from '@/capabilities/codexMcpResume'; import { resumeChecklistId } from '@happier-dev/protocol/checklists'; +import { INSTALLABLE_KEYS } from '@happier-dev/protocol/installables'; import type { CapabilitiesDetectRequest } from '@/sync/api/capabilities/capabilitiesProtocol'; import type { @@ -96,8 +97,8 @@ export function getCodexNewSessionRelevantInstallableDepKeys(ctx: NewSessionRele }); const keys: string[] = []; - if (extras?.experimentalCodexResume === true) keys.push('codex-mcp-resume'); - if (extras?.experimentalCodexAcp === true) keys.push('codex-acp'); + if (extras?.experimentalCodexResume === true) keys.push(INSTALLABLE_KEYS.CODEX_MCP_RESUME); + if (extras?.experimentalCodexAcp === true) keys.push(INSTALLABLE_KEYS.CODEX_ACP); return keys; } diff --git a/apps/ui/sources/agents/providers/copilot/core.ts b/apps/ui/sources/agents/providers/copilot/core.ts new file mode 100644 index 000000000..5c7baf9d9 --- /dev/null +++ b/apps/ui/sources/agents/providers/copilot/core.ts @@ -0,0 +1,49 @@ +import type { AgentCoreConfig } from '@/agents/registry/registryCore'; +import { getAgentModelConfig, getAgentSessionModesKind } from '@happier-dev/agents'; + +export const COPILOT_CORE: AgentCoreConfig = { + id: 'copilot', + displayNameKey: 'agentInput.agent.copilot', + subtitleKey: 'profiles.aiBackend.copilotSubtitleExperimental', + permissionModeI18nPrefix: 'agentInput.codexPermissionMode', + availability: { experimental: true }, + connectedService: { + id: null, + name: 'Copilot', + connectRoute: null, + }, + flavorAliases: ['copilot', 'github-copilot', 'copilot-cli'], + cli: { + detectKey: 'copilot', + machineLoginKey: 'copilot', + installBanner: { + installKind: 'command', + installCommand: 'npm install -g @github/copilot', + }, + spawnAgent: 'copilot', + }, + permissions: { + modeGroup: 'codexLike', + promptProtocol: 'codexDecision', + }, + sessionModes: { + kind: getAgentSessionModesKind('copilot'), + }, + model: getAgentModelConfig('copilot'), + resume: { + vendorResumeIdField: 'copilotSessionId', + uiVendorResumeIdLabelKey: 'sessionInfo.copilotSessionId', + uiVendorResumeIdCopiedKey: 'sessionInfo.copilotSessionIdCopied', + supportsVendorResume: false, + runtimeGate: 'acpLoadSession', + experimental: false, + }, + toolRendering: { + hideUnknownToolsByDefault: true, + }, + ui: { + agentPickerIconName: 'code-slash-outline', + cliGlyphScale: 1.0, + profileCompatibilityGlyphScale: 1.0, + }, +}; diff --git a/apps/ui/sources/agents/providers/copilot/settings/plugin.ts b/apps/ui/sources/agents/providers/copilot/settings/plugin.ts new file mode 100644 index 000000000..871ef4511 --- /dev/null +++ b/apps/ui/sources/agents/providers/copilot/settings/plugin.ts @@ -0,0 +1,7 @@ +import { createNoopProviderSettingsPlugin } from '@/agents/providers/_shared/createNoopProviderSettingsPlugin'; + +export const COPILOT_PROVIDER_SETTINGS_PLUGIN = createNoopProviderSettingsPlugin({ + providerId: 'copilot', + title: 'Copilot', + icon: { ionName: 'logo-github', color: '#24292e' }, +}); diff --git a/apps/ui/sources/agents/providers/copilot/ui.ts b/apps/ui/sources/agents/providers/copilot/ui.ts new file mode 100644 index 000000000..621a6821f --- /dev/null +++ b/apps/ui/sources/agents/providers/copilot/ui.ts @@ -0,0 +1,14 @@ +import type { UnistylesThemes } from 'react-native-unistyles'; + +import type { AgentUiConfig } from '@/agents/registry/registryUi'; + +export const COPILOT_UI: AgentUiConfig = { + id: 'copilot', + icon: require('@/assets/images/icon-monochrome.png'), + tintColor: (theme: UnistylesThemes[keyof UnistylesThemes]) => theme.colors.text, + avatarOverlay: { + circleScale: 0.35, + iconScale: ({ size }: { size: number }) => Math.round(size * 0.25), + }, + cliGlyph: 'CP', +}; diff --git a/apps/ui/sources/agents/providers/pi/PiThinkingChip.tsx b/apps/ui/sources/agents/providers/pi/PiThinkingChip.tsx index 79b54d773..7807dd41c 100644 --- a/apps/ui/sources/agents/providers/pi/PiThinkingChip.tsx +++ b/apps/ui/sources/agents/providers/pi/PiThinkingChip.tsx @@ -1,11 +1,13 @@ import { Ionicons } from '@expo/vector-icons'; import { providers } from '@happier-dev/agents'; import * as React from 'react'; -import { Pressable, Text } from 'react-native'; +import { Pressable } from 'react-native'; import { hapticsLight } from '@/components/ui/theme/haptics'; import type { AgentInputExtraActionChip } from '@/components/sessions/agentInput'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + const THINKING_LEVELS: ReadonlyArray<string> = ['', ...providers.pi.PI_THINKING_LEVELS]; diff --git a/apps/ui/sources/agents/registry/registryCore.ts b/apps/ui/sources/agents/registry/registryCore.ts index 52c16dbbb..32f8e5252 100644 --- a/apps/ui/sources/agents/registry/registryCore.ts +++ b/apps/ui/sources/agents/registry/registryCore.ts @@ -13,6 +13,7 @@ import { QWEN_CORE } from '@/agents/providers/qwen/core'; import { KIMI_CORE } from '@/agents/providers/kimi/core'; import { KILO_CORE } from '@/agents/providers/kilo/core'; import { PI_CORE } from '@/agents/providers/pi/core'; +import { COPILOT_CORE } from '@/agents/providers/copilot/core'; export { AGENT_IDS, DEFAULT_AGENT_ID }; export type { AgentId }; @@ -184,6 +185,7 @@ export const AGENTS_CORE: Readonly<Record<AgentId, AgentCoreConfig>> = Object.fr kimi: KIMI_CORE, kilo: KILO_CORE, pi: PI_CORE, + copilot: COPILOT_CORE, }); export function isAgentId(value: unknown): value is AgentId { diff --git a/apps/ui/sources/agents/registry/registryUi.ts b/apps/ui/sources/agents/registry/registryUi.ts index cad5f22a9..eb207092c 100644 --- a/apps/ui/sources/agents/registry/registryUi.ts +++ b/apps/ui/sources/agents/registry/registryUi.ts @@ -12,6 +12,7 @@ import { QWEN_UI } from '@/agents/providers/qwen/ui'; import { KIMI_UI } from '@/agents/providers/kimi/ui'; import { KILO_UI } from '@/agents/providers/kilo/ui'; import { PI_UI } from '@/agents/providers/pi/ui'; +import { COPILOT_UI } from '@/agents/providers/copilot/ui'; export type AgentUiConfig = Readonly<{ id: AgentId; @@ -43,6 +44,7 @@ export const AGENTS_UI: Readonly<Record<AgentId, AgentUiConfig>> = Object.freeze kimi: KIMI_UI, kilo: KILO_UI, pi: PI_UI, + copilot: COPILOT_UI, }); export function getAgentIconSource(agentId: AgentId): ImageSourcePropType { diff --git a/apps/ui/sources/agents/registry/registryUiBehavior.newSession.test.ts b/apps/ui/sources/agents/registry/registryUiBehavior.newSession.test.ts index 3e52c6b7c..c1a958826 100644 --- a/apps/ui/sources/agents/registry/registryUiBehavior.newSession.test.ts +++ b/apps/ui/sources/agents/registry/registryUiBehavior.newSession.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { CODEX_ACP_DEP_ID, CODEX_MCP_RESUME_DEP_ID, INSTALLABLE_KEYS } from '@happier-dev/protocol/installables'; import { getAgentResumeExperimentsFromSettings, @@ -14,7 +15,7 @@ describe('getNewSessionRelevantInstallableDepKeys', () => { agentId: 'codex', experiments: getAgentResumeExperimentsFromSettings('codex', mcpResume), resumeSessionId: 'x1', - })).toEqual(['codex-mcp-resume']); + })).toEqual([INSTALLABLE_KEYS.CODEX_MCP_RESUME]); expect(getNewSessionRelevantInstallableDepKeys({ agentId: 'codex', @@ -27,7 +28,7 @@ describe('getNewSessionRelevantInstallableDepKeys', () => { agentId: 'codex', experiments: getAgentResumeExperimentsFromSettings('codex', acp), resumeSessionId: '', - })).toEqual(['codex-acp']); + })).toEqual([INSTALLABLE_KEYS.CODEX_ACP]); const mcp = makeSettings({ codexBackendMode: 'mcp' }); expect(getNewSessionRelevantInstallableDepKeys({ @@ -55,8 +56,8 @@ describe('getNewSessionPreflightIssues', () => { experiments: getAgentResumeExperimentsFromSettings('codex', settings), resumeSessionId: 'x1', results: makeResults({ - 'dep.codex-mcp-resume': okCapability({ installed: false }), - 'dep.codex-acp': okCapability({ installed: false }), + [CODEX_MCP_RESUME_DEP_ID]: okCapability({ installed: false }), + [CODEX_ACP_DEP_ID]: okCapability({ installed: false }), }), }); // Codex ACP can run via npx fallback now; do not block new sessions when the optional dep isn't installed. diff --git a/apps/ui/sources/agents/registry/registryUiBehavior.resume.test.ts b/apps/ui/sources/agents/registry/registryUiBehavior.resume.test.ts index 331696eff..aaf259969 100644 --- a/apps/ui/sources/agents/registry/registryUiBehavior.resume.test.ts +++ b/apps/ui/sources/agents/registry/registryUiBehavior.resume.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { CODEX_ACP_DEP_ID, CODEX_MCP_RESUME_DEP_ID } from '@happier-dev/protocol/installables'; import { buildResumeCapabilityOptionsFromUiState, @@ -16,7 +17,7 @@ describe('getResumePreflightIssues', () => { agentId: 'codex', experiments: getAgentResumeExperimentsFromSettings('codex', settings), results: { - 'dep.codex-mcp-resume': { ok: true, checkedAt: 1, data: { installed: false } }, + [CODEX_MCP_RESUME_DEP_ID]: { ok: true, checkedAt: 1, data: { installed: false } }, }, })).toEqual([ expect.objectContaining({ @@ -32,7 +33,7 @@ describe('getResumePreflightIssues', () => { agentId: 'codex', experiments: getAgentResumeExperimentsFromSettings('codex', settings), results: { - 'dep.codex-acp': { ok: true, checkedAt: 1, data: { installed: false } }, + [CODEX_ACP_DEP_ID]: { ok: true, checkedAt: 1, data: { installed: false } }, }, })).toEqual([]); }); @@ -43,8 +44,8 @@ describe('getResumePreflightIssues', () => { agentId: 'codex', experiments: getAgentResumeExperimentsFromSettings('codex', mcp), results: makeResults({ - 'dep.codex-acp': okCapability({ installed: false }), - 'dep.codex-mcp-resume': okCapability({ installed: false }), + [CODEX_ACP_DEP_ID]: okCapability({ installed: false }), + [CODEX_MCP_RESUME_DEP_ID]: okCapability({ installed: false }), }), })).toEqual([ expect.objectContaining({ diff --git a/apps/ui/sources/app/(app)/_layout.tsx b/apps/ui/sources/app/(app)/_layout.tsx index 02c3cc44c..246aa4463 100644 --- a/apps/ui/sources/app/(app)/_layout.tsx +++ b/apps/ui/sources/app/(app)/_layout.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import * as Notifications from 'expo-notifications'; import { Typography } from '@/constants/Typography'; import { createHeader } from '@/components/navigation/Header'; -import { Platform, TouchableOpacity, Text } from 'react-native'; +import { Platform, TouchableOpacity } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { isRunningOnMac } from '@/utils/platform/platform'; import { coerceRelativeRoute } from '@/utils/path/routeUtils'; @@ -19,6 +19,8 @@ import { clearPendingNotificationNav, getPendingNotificationNav, setPendingNotif import { getPendingTerminalConnect } from '@/sync/domains/pending/pendingTerminalConnect'; import { createServerUrlComparableKey } from '@/sync/domains/server/url/serverUrlCanonical'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { Text } from '@/components/ui/text/Text'; + export const unstable_settings = { initialRouteName: 'index', diff --git a/apps/ui/sources/app/(app)/artifacts/[id].tsx b/apps/ui/sources/app/(app)/artifacts/[id].tsx index a2cc8d6f3..8f9c0a05d 100644 --- a/apps/ui/sources/app/(app)/artifacts/[id].tsx +++ b/apps/ui/sources/app/(app)/artifacts/[id].tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, ScrollView, ActivityIndicator, Pressable, Platform } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { useArtifact } from '@/sync/domains/state/storage'; import { useLocalSearchParams, useRouter, Stack } from 'expo-router'; import { StyleSheet } from 'react-native-unistyles'; diff --git a/apps/ui/sources/app/(app)/artifacts/edit/[id].tsx b/apps/ui/sources/app/(app)/artifacts/edit/[id].tsx index d797beb44..f361ba16d 100644 --- a/apps/ui/sources/app/(app)/artifacts/edit/[id].tsx +++ b/apps/ui/sources/app/(app)/artifacts/edit/[id].tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { View, ScrollView, TextInput, Pressable, ActivityIndicator, Platform, KeyboardAvoidingView as RNKeyboardAvoidingView } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { View, ScrollView, Pressable, ActivityIndicator, Platform, KeyboardAvoidingView as RNKeyboardAvoidingView } from 'react-native'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { useRouter, Stack, useLocalSearchParams } from 'expo-router'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; diff --git a/apps/ui/sources/app/(app)/artifacts/index.tsx b/apps/ui/sources/app/(app)/artifacts/index.tsx index 95c01505b..d4326d3c9 100644 --- a/apps/ui/sources/app/(app)/artifacts/index.tsx +++ b/apps/ui/sources/app/(app)/artifacts/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, FlatList, Pressable, ActivityIndicator } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { useArtifacts } from '@/sync/domains/state/storage'; import { DecryptedArtifact } from '@/sync/domains/artifacts/artifactTypes'; import { Ionicons } from '@expo/vector-icons'; @@ -108,14 +108,14 @@ const stylesheet = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.fab.background, alignItems: 'center', justifyContent: 'center', - shadowColor: '#000', + shadowColor: theme.colors.shadow.color, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4, }, fabIcon: { - color: '#FFFFFF', + color: theme.colors.fab.icon, }, })); diff --git a/apps/ui/sources/app/(app)/artifacts/new.tsx b/apps/ui/sources/app/(app)/artifacts/new.tsx index f7a2a5842..3ce6e29dc 100644 --- a/apps/ui/sources/app/(app)/artifacts/new.tsx +++ b/apps/ui/sources/app/(app)/artifacts/new.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { View, ScrollView, TextInput, Pressable, ActivityIndicator, Platform, KeyboardAvoidingView as RNKeyboardAvoidingView } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { View, ScrollView, Pressable, ActivityIndicator, Platform, KeyboardAvoidingView as RNKeyboardAvoidingView } from 'react-native'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { useRouter, Stack } from 'expo-router'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; diff --git a/apps/ui/sources/app/(app)/automations/edit.tsx b/apps/ui/sources/app/(app)/automations/edit.tsx index 32edc020f..44adb4160 100644 --- a/apps/ui/sources/app/(app)/automations/edit.tsx +++ b/apps/ui/sources/app/(app)/automations/edit.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Platform, Pressable, TextInput, View } from 'react-native'; +import { Platform, Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; @@ -13,7 +13,7 @@ import { sync } from '@/sync/sync'; import { t } from '@/text'; import { layout } from '@/components/ui/layout/layout'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { updateExistingSessionAutomationTemplateMessage } from '@/sync/domains/automations/automationExistingSessionTemplateUpdate'; import { tryDecodeAutomationTemplateEnvelope } from '@/sync/domains/automations/automationTemplateTransport'; import { decodeAutomationTemplate } from '@/sync/domains/automations/automationTemplateCodec'; diff --git a/apps/ui/sources/app/(app)/changelog.featureGate.test.tsx b/apps/ui/sources/app/(app)/changelog.featureGate.test.tsx index 2ec6b1cf9..4d0a8c061 100644 --- a/apps/ui/sources/app/(app)/changelog.featureGate.test.tsx +++ b/apps/ui/sources/app/(app)/changelog.featureGate.test.tsx @@ -5,16 +5,16 @@ import renderer, { act } from 'react-test-renderer'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; const mmkvAccess = vi.hoisted(() => ({ - getNumber: vi.fn((..._args: unknown[]) => { - throw new Error('MMKV.getNumber should not be called when changelog UI is disabled'); - }), - set: vi.fn((..._args: unknown[]) => { - throw new Error('MMKV.set should not be called when changelog UI is disabled'); - }), + getString: vi.fn((..._args: unknown[]) => undefined), + getNumber: vi.fn((..._args: unknown[]) => undefined), + set: vi.fn((..._args: unknown[]) => {}), })); vi.mock('react-native-mmkv', () => { class MMKV { + getString(...args: any[]) { + return mmkvAccess.getString(...args); + } getNumber(...args: any[]) { return mmkvAccess.getNumber(...args); } @@ -30,6 +30,8 @@ vi.mock('react-native', () => ({ View: (props: any) => React.createElement('View', props, props.children), Text: (props: any) => React.createElement('Text', props, props.children), ScrollView: (props: any) => React.createElement('ScrollView', props, props.children), + Platform: { OS: 'web', select: (options: any) => options?.web ?? options?.default ?? options?.ios ?? options?.android }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); vi.mock('react-native-unistyles', () => ({ @@ -39,9 +41,11 @@ vi.mock('react-native-unistyles', () => ({ colors: { surface: '#fff', surfaceHigh: '#fff', + divider: '#ddd', text: '#000', textSecondary: '#666', textLink: '#00f', + shadow: { color: '#000', opacity: 0.2 }, }, }; const runtime = {}; @@ -53,9 +57,11 @@ vi.mock('react-native-unistyles', () => ({ colors: { surface: '#fff', surfaceHigh: '#fff', + divider: '#ddd', text: '#000', textSecondary: '#666', textLink: '#00f', + shadow: { color: '#000', opacity: 0.2 }, }, }, }), diff --git a/apps/ui/sources/app/(app)/changelog.tsx b/apps/ui/sources/app/(app)/changelog.tsx index 8b3876744..a8cef6cb5 100644 --- a/apps/ui/sources/app/(app)/changelog.tsx +++ b/apps/ui/sources/app/(app)/changelog.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { ScrollView, View, Text } from 'react-native'; +import { ScrollView, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MarkdownView } from '@/components/markdown/MarkdownView'; @@ -9,6 +9,8 @@ import { layout } from '@/components/ui/layout/layout'; import { t } from '@/text'; import type { FeatureId } from '@happier-dev/protocol'; import { getFeatureBuildPolicyDecision } from '@/sync/domains/features/featureBuildPolicy'; +import { Text } from '@/components/ui/text/Text'; + const CHANGELOG_FEATURE_ID = 'app.ui.changelog' as const satisfies FeatureId; diff --git a/apps/ui/sources/app/(app)/dev/tools2.tsx b/apps/ui/sources/app/(app)/dev/tools2.tsx index ac4b76a8d..98b02569e 100644 --- a/apps/ui/sources/app/(app)/dev/tools2.tsx +++ b/apps/ui/sources/app/(app)/dev/tools2.tsx @@ -23,12 +23,13 @@ export default function Tools2Screen() { startedAt: Date.now() - 1900, completedAt: Date.now() - 1000, description: null, - result: `import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; - -export const Header = ({ title }) => { - return ( - <View style={styles.container}> + result: `import React from 'react'; + import { View, Text } from 'react-native'; + import { StyleSheet } from 'react-native-unistyles'; + + export const Header = ({ title }) => { + return ( + <View style={styles.container}> <Text style={styles.title}>{title}</Text> </View> ); diff --git a/apps/ui/sources/app/(app)/friends/manage.tsx b/apps/ui/sources/app/(app)/friends/manage.tsx index 07e711726..df483e3eb 100644 --- a/apps/ui/sources/app/(app)/friends/manage.tsx +++ b/apps/ui/sources/app/(app)/friends/manage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { useAcceptedFriends, useFriendRequests, useRequestedFriends } from '@/sync/domains/state/storage'; import { UserCard } from '@/components/ui/cards/UserCard'; @@ -9,6 +9,8 @@ import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { useRouter } from 'expo-router'; import { useRequireFriendsEnabled } from '@/hooks/friends/useRequireFriendsEnabled'; import { RequireFriendsIdentityForFriends } from '@/components/friends/RequireFriendsIdentityForFriends'; +import { Text } from '@/components/ui/text/Text'; + export default function FriendsManageScreen() { const enabled = useRequireFriendsEnabled(); diff --git a/apps/ui/sources/app/(app)/friends/search.tsx b/apps/ui/sources/app/(app)/friends/search.tsx index 751476632..2e63f85db 100644 --- a/apps/ui/sources/app/(app)/friends/search.tsx +++ b/apps/ui/sources/app/(app)/friends/search.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { View, Text, TextInput, ActivityIndicator, KeyboardAvoidingView, Platform, FlatList } from 'react-native'; +import { View, ActivityIndicator, KeyboardAvoidingView, Platform, FlatList } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { UserSearchResult } from '@/components/friends/UserSearchResult'; import { searchUsersByUsername, sendFriendRequest } from '@/sync/api/social/apiFriends'; @@ -14,6 +14,8 @@ import { useSearch } from '@/hooks/search/useSearch'; import { useRequireFriendsEnabled } from '@/hooks/friends/useRequireFriendsEnabled'; import { HappyError } from '@/utils/errors/errors'; import { RequireFriendsIdentityForFriends } from '@/components/friends/RequireFriendsIdentityForFriends'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export default function SearchFriendsScreen() { const { theme } = useUnistyles(); diff --git a/apps/ui/sources/app/(app)/inbox/index.tsx b/apps/ui/sources/app/(app)/inbox/index.tsx index 1afd4ddb5..e2895f4c9 100644 --- a/apps/ui/sources/app/(app)/inbox/index.tsx +++ b/apps/ui/sources/app/(app)/inbox/index.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, Platform, Pressable } from 'react-native'; +import { View, Platform, Pressable } from 'react-native'; import { useRouter } from 'expo-router'; import { InboxView } from '@/components/navigation/shell/InboxView'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -9,6 +9,8 @@ import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { useRequireFriendsEnabled } from '@/hooks/friends/useRequireFriendsEnabled'; +import { Text } from '@/components/ui/text/Text'; + const styles = StyleSheet.create((theme) => ({ container: { diff --git a/apps/ui/sources/app/(app)/index.autoRedirect.spec.tsx b/apps/ui/sources/app/(app)/index.autoRedirect.spec.tsx index 25569436f..7134cb8fe 100644 --- a/apps/ui/sources/app/(app)/index.autoRedirect.spec.tsx +++ b/apps/ui/sources/app/(app)/index.autoRedirect.spec.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import renderer, { act } from 'react-test-renderer'; import { createWelcomeFeaturesResponse } from './index.testHelpers'; +import type { ServerFeaturesSnapshot } from '@/sync/api/capabilities/serverFeaturesClient'; type ReactActEnvironmentGlobal = typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -20,15 +21,18 @@ vi.mock('react-native-safe-area-context', () => ({ const openURL = vi.fn(async () => true); let externalSignupUrl = 'https://example.test/oauth'; +let externalLoginUrl = 'https://example.test/oauth-login'; const getSuppressedUntilMock = vi.fn(async () => 0); const setPendingExternalAuthMock = vi.fn(async () => true); const clearPendingExternalAuthMock = vi.fn(async () => true); vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', Text: 'Text', View: 'View', Image: 'Image', useWindowDimensions: () => ({ width: 400, height: 800, scale: 1, fontScale: 1 }), + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, Platform: { OS: 'ios', select: (spec: Record<string, unknown>) => (spec && Object.prototype.hasOwnProperty.call(spec, 'ios') ? spec.ios : undefined), @@ -79,6 +83,7 @@ vi.mock('@/auth/providers/registry', () => ({ id: 'github', displayName: 'GitHub', getExternalSignupUrl: async () => externalSignupUrl, + getExternalLoginUrl: async () => externalLoginUrl, }), })); @@ -99,15 +104,39 @@ vi.mock('@/sync/api/capabilities/getReadyServerFeatures', () => ({ getReadyServerFeatures: getServerFeaturesMock, })); +const getServerFeaturesSnapshotMock = vi.fn(async (_params?: unknown): Promise<ServerFeaturesSnapshot> => ({ + status: 'ready', + features: createWelcomeFeaturesResponse({ + signupMethods: [ + { id: 'anonymous', enabled: false }, + { id: 'github', enabled: true }, + ], + requiredProviders: ['github'], + autoRedirectEnabled: true, + autoRedirectProviderId: 'github', + providerOffboardingIntervalSeconds: 86400, + }), +})); + +vi.mock('@/sync/api/capabilities/serverFeaturesClient', () => ({ + getServerFeaturesSnapshot: getServerFeaturesSnapshotMock, +})); + +vi.mock('@/sync/domains/server/serverRuntime', () => ({ + getActiveServerSnapshot: () => ({ serverUrl: 'https://server.test' }), +})); + describe('/ (welcome) auto redirect', () => { beforeEach(() => { openURL.mockClear(); getServerFeaturesMock.mockClear(); + getServerFeaturesSnapshotMock.mockClear(); setPendingExternalAuthMock.mockClear(); clearPendingExternalAuthMock.mockClear(); getSuppressedUntilMock.mockReset(); getSuppressedUntilMock.mockResolvedValue(0); externalSignupUrl = 'https://example.test/oauth'; + externalLoginUrl = 'https://example.test/oauth-login'; }); async function renderWelcomeScreen(): Promise<void> { @@ -175,6 +204,7 @@ describe('/ (welcome) auto redirect', () => { it('does not throw when server features fetch fails', async () => { vi.resetModules(); getServerFeaturesMock.mockRejectedValueOnce(new Error('network')); + getServerFeaturesSnapshotMock.mockResolvedValueOnce({ status: 'error', reason: 'network' }); await renderWelcomeScreen(); expect(openURL).not.toHaveBeenCalled(); @@ -186,4 +216,53 @@ describe('/ (welcome) auto redirect', () => { await renderWelcomeScreen(); expect(openURL).not.toHaveBeenCalled(); }); + + it('auto-starts mTLS login when server enables auth.ui.autoRedirect=mtls', async () => { + vi.resetModules(); + getServerFeaturesSnapshotMock.mockResolvedValueOnce({ + status: 'ready', + features: createWelcomeFeaturesResponse({ + signupMethods: [{ id: 'anonymous', enabled: false }], + loginMethods: [{ id: 'mtls', enabled: true }], + autoRedirectEnabled: true, + autoRedirectProviderId: 'mtls', + providerOffboardingIntervalSeconds: 86400, + }), + }); + + await renderWelcomeScreen(); + expect(openURL).toHaveBeenCalledWith('https://server.test/v1/auth/mtls/start?returnTo=happier%3A%2F%2F%2Fmtls'); + }); + + it('auto-starts keyless provider login when server enables auth.ui.autoRedirect for a keyless login method', async () => { + vi.resetModules(); + getServerFeaturesSnapshotMock.mockResolvedValueOnce({ + status: 'ready', + features: createWelcomeFeaturesResponse({ + signupMethods: [{ id: 'anonymous', enabled: false }], + loginMethods: [], + authMethods: [ + { + id: 'key_challenge', + actions: [ + { id: 'login', enabled: false, mode: 'keyed' }, + { id: 'provision', enabled: false, mode: 'keyed' }, + ], + ui: { displayName: 'Device key', iconHint: null }, + }, + { + id: 'github', + actions: [{ id: 'login', enabled: true, mode: 'keyless' }], + ui: { displayName: 'GitHub', iconHint: 'github' }, + }, + ], + autoRedirectEnabled: true, + autoRedirectProviderId: 'github', + providerOffboardingIntervalSeconds: 86400, + }), + }); + + await renderWelcomeScreen(); + expect(openURL).toHaveBeenCalledWith('https://example.test/oauth-login'); + }); }); diff --git a/apps/ui/sources/app/(app)/index.autoRedirect.web.spec.tsx b/apps/ui/sources/app/(app)/index.autoRedirect.web.spec.tsx new file mode 100644 index 000000000..5a1d2ab1c --- /dev/null +++ b/apps/ui/sources/app/(app)/index.autoRedirect.web.spec.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import { createWelcomeFeaturesResponse } from './index.testHelpers'; +import type { ServerFeaturesSnapshot } from '@/sync/api/capabilities/serverFeaturesClient'; + +type ReactActEnvironmentGlobal = typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +(globalThis as ReactActEnvironmentGlobal).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native-reanimated', () => ({})); +vi.mock('react-native-typography', () => ({ iOSUIKit: { title3: {} } })); +vi.mock('@/components/navigation/shell/HomeHeader', () => ({ HomeHeaderNotAuth: () => null })); +vi.mock('@/components/navigation/shell/MainView', () => ({ MainView: () => null })); +vi.mock('@/components/ui/buttons/RoundButton', () => ({ RoundButton: () => null })); +vi.mock('@shopify/react-native-skia', () => ({})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); + +const openURL = vi.fn(async () => true); +let externalSignupUrl = 'https://example.test/oauth'; +const getSuppressedUntilMock = vi.fn(async () => 0); +const setPendingExternalAuthMock = vi.fn(async () => true); +const clearPendingExternalAuthMock = vi.fn(async () => true); + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Text: 'Text', + View: 'View', + Image: 'Image', + useWindowDimensions: () => ({ width: 400, height: 800, scale: 1, fontScale: 1 }), + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, + Platform: { + OS: 'web', + select: (spec: Record<string, unknown>) => (spec && Object.prototype.hasOwnProperty.call(spec, 'web') ? spec.web : undefined), + }, + Linking: { openURL }, +})); + +vi.mock('@/auth/context/AuthContext', () => ({ + useAuth: () => ({ + isAuthenticated: false, + credentials: null, + login: vi.fn(async () => {}), + logout: vi.fn(async () => {}), + }), +})); + +vi.mock('@/sync/domains/pending/pendingTerminalConnect', () => ({ + getPendingTerminalConnect: () => null, + setPendingTerminalConnect: vi.fn(), + clearPendingTerminalConnect: vi.fn(), +})); + +vi.mock('@/sync/domains/server/serverRuntime', () => ({ + getActiveServerSnapshot: () => ({ serverUrl: '' }), +})); + +vi.mock('@/platform/cryptoRandom', () => ({ + getRandomBytesAsync: async (n: number) => new Uint8Array(n).fill(9), +})); + +vi.mock('@/encryption/base64', () => ({ + encodeBase64: () => 'x', +})); + +vi.mock('@/encryption/libsodium.lib', () => ({ + default: { + crypto_sign_seed_keypair: () => ({ publicKey: new Uint8Array([1]), privateKey: new Uint8Array([2]) }), + }, +})); + +vi.mock('@/auth/storage/tokenStorage', () => ({ + TokenStorage: { + getAuthAutoRedirectSuppressedUntil: () => getSuppressedUntilMock(), + setPendingExternalAuth: () => setPendingExternalAuthMock(), + clearPendingExternalAuth: () => clearPendingExternalAuthMock(), + }, + isLegacyAuthCredentials: (credentials: unknown) => Boolean(credentials), +})); + +vi.mock('@/auth/providers/registry', () => ({ + getAuthProvider: () => ({ + id: 'github', + displayName: 'GitHub', + getExternalSignupUrl: async () => externalSignupUrl, + }), +})); + +const getServerFeaturesMock = vi.fn(async () => + createWelcomeFeaturesResponse({ + signupMethods: [ + { id: 'anonymous', enabled: false }, + { id: 'github', enabled: true }, + ], + requiredProviders: ['github'], + autoRedirectEnabled: true, + autoRedirectProviderId: 'github', + providerOffboardingIntervalSeconds: 86400, + }), +); + +vi.mock('@/sync/api/capabilities/getReadyServerFeatures', () => ({ + getReadyServerFeatures: getServerFeaturesMock, +})); + +const getServerFeaturesSnapshotMock = vi.fn(async (_params?: unknown): Promise<ServerFeaturesSnapshot> => ({ + status: 'ready', + features: createWelcomeFeaturesResponse({ + signupMethods: [ + { id: 'anonymous', enabled: false }, + { id: 'github', enabled: true }, + ], + requiredProviders: ['github'], + autoRedirectEnabled: true, + autoRedirectProviderId: 'github', + providerOffboardingIntervalSeconds: 86400, + }), +})); + +vi.mock('@/sync/api/capabilities/serverFeaturesClient', () => ({ + getServerFeaturesSnapshot: getServerFeaturesSnapshotMock, +})); + +describe('/ (welcome) auto redirect on web', () => { + beforeEach(() => { + openURL.mockClear(); + getServerFeaturesMock.mockClear(); + getServerFeaturesSnapshotMock.mockClear(); + setPendingExternalAuthMock.mockClear(); + clearPendingExternalAuthMock.mockClear(); + getSuppressedUntilMock.mockReset(); + getSuppressedUntilMock.mockResolvedValue(0); + externalSignupUrl = 'https://example.test/oauth'; + }); + + it('navigates in the current tab on web to avoid popup-blocker failures', async () => { + vi.resetModules(); + + const assign = vi.fn(); + const originalWindow = (globalThis as any).window; + (globalThis as any).window = { location: { assign } }; + + const { default: Screen } = await import('./index'); + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + await act(async () => {}); + + expect(assign).toHaveBeenCalledWith('https://example.test/oauth'); + expect(openURL).not.toHaveBeenCalled(); + } finally { + act(() => { + tree?.unmount(); + }); + (globalThis as any).window = originalWindow; + } + }); +}); diff --git a/apps/ui/sources/app/(app)/index.pendingTerminalIntent.spec.tsx b/apps/ui/sources/app/(app)/index.pendingTerminalIntent.spec.tsx index f6f3f4b93..4bb507755 100644 --- a/apps/ui/sources/app/(app)/index.pendingTerminalIntent.spec.tsx +++ b/apps/ui/sources/app/(app)/index.pendingTerminalIntent.spec.tsx @@ -47,6 +47,39 @@ vi.mock('@/sync/api/capabilities/getReadyServerFeatures', () => ({ }), })); +vi.mock('@/sync/api/capabilities/serverFeaturesClient', () => ({ + getServerFeaturesSnapshot: async () => ({ + status: 'ready', + features: { + features: { + sharing: { session: { enabled: true }, public: { enabled: true }, contentKeys: { enabled: true }, pendingQueueV2: { enabled: true } }, + voice: { enabled: false, happierVoice: { enabled: false } }, + social: { friends: { enabled: false } }, + auth: { login: { keyChallenge: { enabled: true } }, recovery: { providerReset: { enabled: false } }, ui: { recoveryKeyReminder: { enabled: true } } }, + }, + capabilities: { + oauth: { providers: { github: { enabled: true, configured: true } } }, + auth: { + signup: { methods: [{ id: 'anonymous', enabled: true }] }, + login: { requiredProviders: ['github'], methods: [{ id: 'key_challenge', enabled: true }] }, + recovery: { providerReset: { providers: ['github'] } }, + ui: { autoRedirect: { enabled: false, providerId: null } }, + providers: { + github: { + enabled: true, + configured: true, + ui: { displayName: 'GitHub', iconHint: 'github', connectButtonColor: '#24292F', supportsProfileBadge: true, badgeIconName: 'github' }, + restrictions: { usersAllowlist: false, orgsAllowlist: false, orgMatch: 'any' }, + offboarding: { enabled: false, intervalSeconds: 600, mode: 'per-request-cache', source: 'github_app' }, + }, + }, + misconfig: [], + }, + }, + }, + }), +})); + vi.mock('@/sync/domains/pending/pendingTerminalConnect', () => ({ getPendingTerminalConnect: () => ({ publicKeyB64Url: 'abc123', serverUrl: 'https://company.example.test' }), setPendingTerminalConnect: vi.fn(), @@ -64,6 +97,9 @@ describe('/ (welcome) terminal connect intent notice', () => { }); await act(async () => {}); + const intentBlocks = tree!.root.findAll((n) => n.props?.testID === 'welcome-terminal-connect-intent'); + expect(intentBlocks).toHaveLength(1); + const textValues = tree!.root .findAll((n) => typeof n.props?.children === 'string') .map((n) => String(n.props.children)); diff --git a/apps/ui/sources/app/(app)/index.signupMethods.spec.tsx b/apps/ui/sources/app/(app)/index.signupMethods.spec.tsx index c9b0e369c..97fe052ce 100644 --- a/apps/ui/sources/app/(app)/index.signupMethods.spec.tsx +++ b/apps/ui/sources/app/(app)/index.signupMethods.spec.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import renderer, { act } from 'react-test-renderer'; -import { t } from '@/text'; import { createWelcomeFeaturesResponse } from './index.testHelpers'; +import type { ServerFeaturesSnapshot } from '@/sync/api/capabilities/serverFeaturesClient'; +import type { FeaturesResponse } from '@happier-dev/protocol'; type ReactActEnvironmentGlobal = typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -33,7 +34,7 @@ vi.mock('@/sync/domains/pending/pendingTerminalConnect', () => ({ clearPendingTerminalConnect: vi.fn(), })); -const getServerFeaturesMock = vi.fn(async () => +const getReadyServerFeaturesMock = vi.fn(async () => createWelcomeFeaturesResponse({ signupMethods: [ { id: 'anonymous', enabled: false }, @@ -47,13 +48,150 @@ const getServerFeaturesMock = vi.fn(async () => ); vi.mock('@/sync/api/capabilities/getReadyServerFeatures', () => ({ - getReadyServerFeatures: getServerFeaturesMock, + getReadyServerFeatures: getReadyServerFeaturesMock, +})); + +const defaultWelcomeFeatures = createWelcomeFeaturesResponse({ + signupMethods: [ + { id: 'anonymous', enabled: false }, + { id: 'github', enabled: true }, + ], + requiredProviders: ['github'], + autoRedirectEnabled: false, + autoRedirectProviderId: null, + providerOffboardingIntervalSeconds: 600, +}); + +const getServerFeaturesSnapshotMock = vi.fn(async (_params?: unknown): Promise<ServerFeaturesSnapshot> => ({ + status: 'ready', + features: defaultWelcomeFeatures, +})); + +vi.mock('@/sync/api/capabilities/serverFeaturesClient', () => ({ + getServerFeaturesSnapshot: getServerFeaturesSnapshotMock, })); describe('/ (welcome) signup methods', () => { + beforeEach(() => { + getReadyServerFeaturesMock.mockReset(); + getReadyServerFeaturesMock.mockResolvedValue(defaultWelcomeFeatures); + getServerFeaturesSnapshotMock.mockReset(); + getServerFeaturesSnapshotMock.mockResolvedValue({ status: 'ready', features: defaultWelcomeFeatures }); + }); + + it('shows Create account and provider option when both are enabled', async () => { + vi.resetModules(); + const { t } = await import('@/text'); + const bothEnabled = createWelcomeFeaturesResponse({ + signupMethods: [ + { id: 'anonymous', enabled: true }, + { id: 'github', enabled: true }, + ], + requiredProviders: [], + autoRedirectEnabled: false, + autoRedirectProviderId: null, + providerOffboardingIntervalSeconds: 600, + }); + getServerFeaturesSnapshotMock.mockResolvedValueOnce({ status: 'ready', features: bothEnabled }); + + const { default: Screen } = await import('./index'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + if (!tree) { + throw new Error('Expected welcome screen renderer'); + } + + let textValues: string[] = []; + for (let turn = 0; turn < 10; turn += 1) { + await act(async () => {}); + textValues = tree.root + .findAll((n) => typeof n.props?.children === 'string') + .map((n) => String(n.props.children)); + if (textValues.includes(t('welcome.createAccount')) && textValues.includes(t('welcome.signUpWithProvider', { provider: 'GitHub' }))) { + break; + } + } + + expect(textValues).toContain(t('welcome.createAccount')); + expect(textValues).toContain(t('welcome.signUpWithProvider', { provider: 'GitHub' })); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); + + it('prefers auth.methods over legacy signup/login methods when present', async () => { + vi.resetModules(); + const { t } = await import('@/text'); + + const authMethods = [ + { + id: 'key_challenge', + actions: [ + { id: 'login' as const, enabled: true, mode: 'keyed' as const }, + { id: 'provision' as const, enabled: false, mode: 'keyed' as const }, + ], + ui: { displayName: 'Device key', iconHint: null }, + }, + { + id: 'github', + actions: [{ id: 'provision' as const, enabled: true, mode: 'keyed' as const }], + ui: { displayName: 'GitHub', iconHint: 'github' }, + }, + ] satisfies NonNullable<FeaturesResponse['capabilities']['auth']['methods']>; + + const payload = createWelcomeFeaturesResponse({ + // Legacy says anonymous signup is enabled… + signupMethods: [ + { id: 'anonymous', enabled: true }, + { id: 'github', enabled: true }, + ], + // …but auth.methods disables key_challenge provisioning, so Create account must be hidden. + authMethods, + requiredProviders: [], + autoRedirectEnabled: false, + autoRedirectProviderId: null, + providerOffboardingIntervalSeconds: 600, + }); + getServerFeaturesSnapshotMock.mockResolvedValueOnce({ status: 'ready', features: payload }); + + const { default: Screen } = await import('./index'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + if (!tree) throw new Error('Expected welcome screen renderer'); + + let textValues: string[] = []; + for (let turn = 0; turn < 10; turn += 1) { + await act(async () => {}); + textValues = tree.root + .findAll((n) => typeof n.props?.children === 'string') + .map((n) => String(n.props.children)); + if (textValues.includes(t('welcome.signUpWithProvider', { provider: 'GitHub' }))) { + break; + } + } + + expect(textValues).toContain(t('welcome.signUpWithProvider', { provider: 'GitHub' })); + expect(textValues).not.toContain(t('welcome.createAccount')); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); + it('hides Create account when anonymous signup is disabled and shows provider option', async () => { vi.resetModules(); - getServerFeaturesMock.mockClear(); + const { t } = await import('@/text'); const { default: Screen } = await import('./index'); let tree: ReturnType<typeof renderer.create> | undefined; @@ -61,14 +199,20 @@ describe('/ (welcome) signup methods', () => { await act(async () => { tree = renderer.create(<Screen />); }); - await act(async () => {}); if (!tree) { throw new Error('Expected welcome screen renderer'); } - const textValues = tree.root - .findAll((n) => typeof n.props?.children === 'string') - .map((n) => String(n.props.children)); + let textValues: string[] = []; + for (let turn = 0; turn < 10; turn += 1) { + await act(async () => {}); + textValues = tree.root + .findAll((n) => typeof n.props?.children === 'string') + .map((n) => String(n.props.children)); + if (textValues.includes(t('welcome.signUpWithProvider', { provider: 'GitHub' }))) { + break; + } + } expect(textValues).not.toContain(t('welcome.createAccount')); expect(textValues).toContain(t('welcome.signUpWithProvider', { provider: 'GitHub' })); @@ -78,4 +222,187 @@ describe('/ (welcome) signup methods', () => { }); } }); + + it('shows mTLS login when signup methods are disabled but mTLS is enabled', async () => { + vi.resetModules(); + const { t } = await import('@/text'); + const mtlsOnly = createWelcomeFeaturesResponse({ + signupMethods: [{ id: 'anonymous', enabled: false }], + loginMethods: [{ id: 'mtls', enabled: true }], + authMtlsEnabled: true, + requiredProviders: [], + autoRedirectEnabled: false, + autoRedirectProviderId: null, + providerOffboardingIntervalSeconds: 600, + }); + getServerFeaturesSnapshotMock.mockResolvedValueOnce({ status: 'ready', features: mtlsOnly }); + + const { default: Screen } = await import('./index'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + if (!tree) throw new Error('Expected welcome screen renderer'); + + let textValues: string[] = []; + for (let turn = 0; turn < 10; turn += 1) { + await act(async () => {}); + textValues = tree.root + .findAll((n) => typeof n.props?.children === 'string') + .map((n) => String(n.props.children)); + if (textValues.includes(t('welcome.signInWithCertificate'))) { + break; + } + } + + expect(textValues).toContain(t('welcome.signInWithCertificate')); + expect(textValues).not.toContain(t('welcome.createAccount')); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); + + it('shows keyless provider login when signup methods are disabled but a keyless OAuth login method is enabled', async () => { + vi.resetModules(); + const { t } = await import('@/text'); + const keylessOnly = createWelcomeFeaturesResponse({ + signupMethods: [{ id: 'anonymous', enabled: false }], + loginMethods: [], + authMethods: [ + { + id: 'key_challenge', + actions: [ + { id: 'login', enabled: false, mode: 'keyed' }, + { id: 'provision', enabled: false, mode: 'keyed' }, + ], + ui: { displayName: 'Device key', iconHint: null }, + }, + { + id: 'github', + actions: [{ id: 'login', enabled: true, mode: 'keyless' }], + ui: { displayName: 'GitHub', iconHint: 'github' }, + }, + ], + requiredProviders: [], + autoRedirectEnabled: false, + autoRedirectProviderId: null, + providerOffboardingIntervalSeconds: 600, + }); + getServerFeaturesSnapshotMock.mockResolvedValueOnce({ status: 'ready', features: keylessOnly }); + + const { default: Screen } = await import('./index'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + if (!tree) throw new Error('Expected welcome screen renderer'); + + let textValues: string[] = []; + for (let turn = 0; turn < 10; turn += 1) { + await act(async () => {}); + textValues = tree.root + .findAll((n) => typeof n.props?.children === 'string') + .map((n) => String(n.props.children)); + if (textValues.includes(t('welcome.signUpWithProvider', { provider: 'GitHub' }))) { + break; + } + } + + expect(textValues).toContain(t('welcome.signUpWithProvider', { provider: 'GitHub' })); + expect(textValues).not.toContain(t('welcome.createAccount')); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); + + it('shows a server unavailable notice and hides auth actions when the server cannot be reached', async () => { + vi.resetModules(); + const { t } = await import('@/text'); + getReadyServerFeaturesMock.mockRejectedValueOnce(new Error('network')); + getServerFeaturesSnapshotMock.mockClear(); + getServerFeaturesSnapshotMock.mockResolvedValue({ status: 'error', reason: 'network' }); + + const { default: Screen } = await import('./index'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + if (!tree) throw new Error('Expected welcome screen renderer'); + + for (let turn = 0; turn < 10; turn += 1) { + await act(async () => {}); + const unavailableBlocks = tree.root.findAll((n) => n.props?.testID === 'welcome-server-unavailable'); + if (unavailableBlocks.length > 0) break; + } + + expect(getServerFeaturesSnapshotMock).toHaveBeenCalled(); + + const unavailableBlocks = tree.root.findAll((n) => n.props?.testID === 'welcome-server-unavailable'); + expect(unavailableBlocks).toHaveLength(1); + const unavailableTextValues = unavailableBlocks[0]!.findAll((n) => typeof n.props?.children === 'string') + .map((n) => String(n.props.children)); + + expect(unavailableTextValues).toContain(t('welcome.serverUnavailableTitle')); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-restore')).toHaveLength(0); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-signup-provider')).toHaveLength(0); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-create-account')).toHaveLength(0); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-retry-server' && n.props?.accessibilityRole === 'button')).toHaveLength(1); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-configure-server' && n.props?.accessibilityRole === 'button')).toHaveLength(1); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); + + it('shows a server incompatible notice and hides auth actions when the server features response is invalid', async () => { + vi.resetModules(); + const { t } = await import('@/text'); + getServerFeaturesSnapshotMock.mockClear(); + getServerFeaturesSnapshotMock.mockResolvedValue({ status: 'unsupported', reason: 'invalid_payload' }); + + const { default: Screen } = await import('./index'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + if (!tree) throw new Error('Expected welcome screen renderer'); + + for (let turn = 0; turn < 10; turn += 1) { + await act(async () => {}); + const blocks = tree.root.findAll((n) => n.props?.testID === 'welcome-server-unavailable'); + if (blocks.length > 0) break; + } + + expect(getServerFeaturesSnapshotMock).toHaveBeenCalled(); + + const blocks = tree.root.findAll((n) => n.props?.testID === 'welcome-server-unavailable'); + expect(blocks).toHaveLength(1); + const textValues = blocks[0]!.findAll((n) => typeof n.props?.children === 'string') + .map((n) => String(n.props.children)); + + expect(textValues).toContain(t('welcome.serverIncompatibleTitle')); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-restore')).toHaveLength(0); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-signup-provider')).toHaveLength(0); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-create-account')).toHaveLength(0); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-retry-server' && n.props?.accessibilityRole === 'button')).toHaveLength(1); + expect(tree.root.findAll((n) => n.props?.testID === 'welcome-configure-server' && n.props?.accessibilityRole === 'button')).toHaveLength(1); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); }); diff --git a/apps/ui/sources/app/(app)/index.testHelpers.ts b/apps/ui/sources/app/(app)/index.testHelpers.ts index b3e9628e2..f2b62ddad 100644 --- a/apps/ui/sources/app/(app)/index.testHelpers.ts +++ b/apps/ui/sources/app/(app)/index.testHelpers.ts @@ -6,6 +6,9 @@ type ProviderState = { enabled: boolean; configured: boolean }; type WelcomeFeaturesOverrides = { signupMethods?: Array<{ id: string; enabled: boolean }>; + loginMethods?: Array<{ id: string; enabled: boolean }>; + authMethods?: FeaturesResponse['capabilities']['auth']['methods']; + authMtlsEnabled?: boolean; requiredProviders?: string[]; autoRedirectEnabled?: boolean; autoRedirectProviderId?: string | null; @@ -62,6 +65,37 @@ export function createWelcomeFeaturesResponse( const providerResetEnabled = overrides.recoveryProviderResetEnabled ?? false; const providerResetProviders = overrides.recoveryProviderResetProviders ?? []; + const signupMethods = + overrides.signupMethods ?? [ + { id: 'anonymous', enabled: true }, + { id: 'github', enabled: true }, + ]; + + const loginMethods = overrides.loginMethods ?? [{ id: 'key_challenge', enabled: true }]; + + const derivedAuthMethods = [ + { + id: 'key_challenge', + actions: [ + { id: 'login' as const, enabled: loginMethods.some((m) => m.id === 'key_challenge' && m.enabled), mode: 'keyed' as const }, + { id: 'provision' as const, enabled: signupMethods.some((m) => m.id === 'anonymous' && m.enabled), mode: 'keyed' as const }, + ], + ui: { displayName: 'Device key', iconHint: null }, + }, + { + id: 'mtls', + actions: [{ id: 'login' as const, enabled: loginMethods.some((m) => m.id === 'mtls' && m.enabled), mode: 'keyless' as const }], + ui: { displayName: 'Certificate', iconHint: null }, + }, + ...signupMethods + .filter((m) => m.id !== 'anonymous' && m.enabled) + .map((m) => ({ + id: m.id, + actions: [{ id: 'provision' as const, enabled: true, mode: 'keyed' as const }], + ui: { displayName: m.id, iconHint: null }, + })), + ] satisfies NonNullable<FeaturesResponse['capabilities']['auth']['methods']>; + return createRootLayoutFeaturesResponse({ features: { sharing: { @@ -77,6 +111,11 @@ export function createWelcomeFeaturesResponse( }, }, auth: { + ...(overrides.authMtlsEnabled + ? { + mtls: { enabled: true }, + } + : {}), recovery: { providerReset: { enabled: providerResetEnabled }, }, @@ -95,14 +134,11 @@ export function createWelcomeFeaturesResponse( }, oauth: { providers: oauthProviders }, auth: { + methods: overrides.authMethods ?? derivedAuthMethods, signup: { - methods: - overrides.signupMethods ?? [ - { id: 'anonymous', enabled: true }, - { id: 'github', enabled: true }, - ], + methods: signupMethods, }, - login: { requiredProviders: overrides.requiredProviders ?? [] }, + login: { methods: loginMethods, requiredProviders: overrides.requiredProviders ?? [] }, recovery: { providerReset: { providers: providerResetEnabled ? providerResetProviders : [] } }, ui: { autoRedirect: { diff --git a/apps/ui/sources/app/(app)/index.tsx b/apps/ui/sources/app/(app)/index.tsx index fec543b3a..38eca91c3 100644 --- a/apps/ui/sources/app/(app)/index.tsx +++ b/apps/ui/sources/app/(app)/index.tsx @@ -1,6 +1,6 @@ import { RoundButton } from "@/components/ui/buttons/RoundButton"; import { useAuth } from "@/auth/context/AuthContext"; -import { Text, View, Image, Platform, Linking } from "react-native"; +import { ActivityIndicator, View, Image, Platform, Linking } from 'react-native'; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as React from 'react'; import { encodeBase64 } from "@/encryption/base64"; @@ -14,7 +14,6 @@ import { trackAccountCreated, trackAccountRestored } from '@/track'; import { HomeHeaderNotAuth } from "@/components/navigation/shell/HomeHeader"; import { MainView } from "@/components/navigation/shell/MainView"; import { t } from '@/text'; -import { getReadyServerFeatures } from "@/sync/api/capabilities/getReadyServerFeatures"; import { TokenStorage } from "@/auth/storage/tokenStorage"; import sodium from '@/encryption/libsodium.lib'; import { getAuthProvider } from "@/auth/providers/registry"; @@ -22,6 +21,14 @@ import { Modal } from "@/modal"; import { getPendingTerminalConnect } from "@/sync/domains/pending/pendingTerminalConnect"; import { isSafeExternalAuthUrl } from "@/auth/providers/externalAuthUrl"; import { fireAndForget } from "@/utils/system/fireAndForget"; +import { formatOperationFailedDebugMessage } from "@/utils/errors/formatOperationFailedDebugMessage"; +import { getActiveServerSnapshot } from "@/sync/domains/server/serverRuntime"; +import { getServerFeaturesSnapshot } from "@/sync/api/capabilities/serverFeaturesClient"; +import { Text } from '@/components/ui/text/Text'; +import { buildDataKeyCredentialsForToken } from "@/auth/flows/buildDataKeyCredentialsForToken"; +import { digest } from "@/platform/digest"; +import { encodeHex } from "@/encryption/hex"; + export default function Home() { const auth = useAuth(); @@ -44,10 +51,18 @@ function NotAuthenticated() { const isLandscape = useIsLandscape(); const insets = useSafeAreaInsets(); - const [signupMode, setSignupMode] = React.useState< - | { kind: "anonymous" } - | { kind: "provider"; providerId: string } - >({ kind: "anonymous" }); + const [serverAvailability, setServerAvailability] = React.useState<'loading' | 'ready' | 'legacy' | 'unavailable' | 'incompatible'>('loading'); + const [serverCheckNonce, setServerCheckNonce] = React.useState(0); + const [signupOptions, setSignupOptions] = React.useState<{ + anonymousEnabled: boolean; + providerIds: readonly string[]; + preferredProviderId: string | null; + }>({ anonymousEnabled: true, providerIds: Object.freeze([]), preferredProviderId: null }); + const [loginOptions, setLoginOptions] = React.useState<{ + mtlsEnabled: boolean; + keylessProviderIds: readonly string[]; + preferredKeylessProviderId: string | null; + }>({ mtlsEnabled: false, keylessProviderIds: Object.freeze([]), preferredKeylessProviderId: null }); const autoRedirectAttemptedRef = React.useRef(false); const hasPendingTerminalConnect = Boolean(getPendingTerminalConnect()); @@ -55,55 +70,141 @@ function NotAuthenticated() { let mounted = true; fireAndForget((async () => { try { - const features = await getReadyServerFeatures(); - const methods = features?.capabilities?.auth?.signup?.methods ?? []; - const enabled = methods + if (mounted) setServerAvailability('loading'); + + const featuresSnapshot = await getServerFeaturesSnapshot({ timeoutMs: 1500, force: serverCheckNonce > 0 }); + if (featuresSnapshot.status === 'error') { + if (mounted) setServerAvailability('unavailable'); + return; + } + if (featuresSnapshot.status === 'unsupported' && featuresSnapshot.reason === 'invalid_payload') { + if (mounted) setServerAvailability('incompatible'); + return; + } + + const features = featuresSnapshot.status === 'ready' ? featuresSnapshot.features : null; + const authMethodsRaw = features?.capabilities?.auth?.methods ?? []; + const authMethods = Array.isArray(authMethodsRaw) ? authMethodsRaw : []; + + const hasAuthMethods = authMethods.length > 0; + + const legacySignupMethods = features?.capabilities?.auth?.signup?.methods ?? []; + const legacyEnabledSignupIds = legacySignupMethods .filter((m) => m.enabled === true) .map((m) => String(m.id).trim().toLowerCase()) .filter(Boolean); - // Default to legacy behavior (anonymous) when features can't be fetched. - if (enabled.length === 0) { - if (mounted) setSignupMode({ kind: "anonymous" }); - return; - } + const legacyLoginMethods = features?.capabilities?.auth?.login?.methods ?? []; + const legacyEnabledLoginIds = legacyLoginMethods + .filter((m) => m.enabled === true) + .map((m) => String(m.id).trim().toLowerCase()) + .filter(Boolean); + + const resolveMethodById = (id: string): any | null => + authMethods.find((m: any) => String(m?.id ?? '').trim().toLowerCase() === id) ?? null; + + const hasEnabledAction = ( + method: any | null, + actionId: 'login' | 'provision', + modes: readonly ('keyed' | 'keyless' | 'either')[], + ): boolean => { + const actions = Array.isArray(method?.actions) ? method.actions : []; + return actions.some((a: any) => a?.enabled === true && a?.id === actionId && modes.includes(a?.mode)); + }; - if (enabled.includes("anonymous")) { - if (mounted) setSignupMode({ kind: "anonymous" }); + const anonymousEnabled = hasAuthMethods + ? hasEnabledAction(resolveMethodById('key_challenge'), 'provision', ['keyed', 'either']) + : legacyEnabledSignupIds.includes('anonymous'); + + const keyedProvisionProviderIds = hasAuthMethods + ? authMethods + .map((m: any) => String(m?.id ?? '').trim().toLowerCase()) + .filter(Boolean) + .filter((id: string) => id !== 'key_challenge' && id !== 'mtls') + .filter((id: string) => hasEnabledAction(resolveMethodById(id), 'provision', ['keyed', 'either'])) + : legacyEnabledSignupIds.filter((id) => id !== 'anonymous'); + + const keylessLoginMethodIds = hasAuthMethods + ? authMethods + .map((m: any) => String(m?.id ?? '').trim().toLowerCase()) + .filter(Boolean) + .filter((id: string) => id !== 'key_challenge') + .filter((id: string) => hasEnabledAction(resolveMethodById(id), 'login', ['keyless', 'either'])) + : legacyEnabledLoginIds.filter((id) => id !== 'key_challenge'); + + const mtlsEnabled = keylessLoginMethodIds.includes('mtls'); + const keylessProviderIds = keylessLoginMethodIds.filter((id) => id !== 'mtls'); + + // Default to legacy behavior (anonymous) when features can't be fetched + // and the server doesn't advertise any viable auth methods. + if (!hasAuthMethods && legacyEnabledSignupIds.length === 0 && legacyEnabledLoginIds.length === 0) { + if (mounted) { + setSignupOptions({ anonymousEnabled: true, providerIds: Object.freeze([]), preferredProviderId: null }); + setLoginOptions({ mtlsEnabled: false, keylessProviderIds: Object.freeze([]), preferredKeylessProviderId: null }); + setServerAvailability('legacy'); + } return; } - // Pick the first enabled external provider, preferring one that is configured. - const providers = enabled.filter((id) => id !== "anonymous"); - const configuredProvider = - providers.find((id) => features?.capabilities?.oauth?.providers?.[id]?.configured === true) ?? null; - const providerId = configuredProvider ?? providers[0] ?? null; + const configuredProviderId = + keyedProvisionProviderIds.find((id) => features?.capabilities?.oauth?.providers?.[id]?.configured === true) ?? null; + const preferredProviderId = configuredProviderId ?? keyedProvisionProviderIds[0] ?? null; + + const configuredKeylessProviderId = + keylessProviderIds.find((id) => features?.capabilities?.oauth?.providers?.[id]?.configured === true) ?? null; + const preferredKeylessProviderId = configuredKeylessProviderId ?? keylessProviderIds[0] ?? null; if (mounted) { - setSignupMode(providerId ? { kind: "provider", providerId } : { kind: "anonymous" }); + setSignupOptions({ + anonymousEnabled, + providerIds: Object.freeze(keyedProvisionProviderIds), + preferredProviderId, + }); + setLoginOptions({ + mtlsEnabled, + keylessProviderIds: Object.freeze(keylessProviderIds), + preferredKeylessProviderId, + }); + setServerAvailability('ready'); } const autoRedirect = features?.capabilities?.auth?.ui?.autoRedirect ?? null; const autoRedirectProviderId = (autoRedirect?.providerId ?? "").trim().toLowerCase(); + const methodForAutoRedirect = hasAuthMethods ? resolveMethodById(autoRedirectProviderId) : null; + const autoRedirectToKeyedProvision = + hasAuthMethods && hasEnabledAction(methodForAutoRedirect, 'provision', ['keyed', 'either']); + const autoRedirectToKeylessLogin = + hasAuthMethods && hasEnabledAction(methodForAutoRedirect, 'login', ['keyless', 'either']); + const autoRedirectToMtls = autoRedirectProviderId === "mtls" && mtlsEnabled; + const autoRedirectToLegacySignupProvider = + !hasAuthMethods && autoRedirectProviderId && legacyEnabledSignupIds.includes(autoRedirectProviderId); if ( !autoRedirectAttemptedRef.current && autoRedirect?.enabled === true && autoRedirectProviderId && - !enabled.includes("anonymous") && - enabled.includes(autoRedirectProviderId) + !anonymousEnabled && + (autoRedirectToMtls || autoRedirectToKeyedProvision || autoRedirectToKeylessLogin || autoRedirectToLegacySignupProvider) ) { autoRedirectAttemptedRef.current = true; const suppressedUntil = await TokenStorage.getAuthAutoRedirectSuppressedUntil(); if (Date.now() < suppressedUntil) return; - await createAccountViaProvider(autoRedirectProviderId); + if (autoRedirectToMtls) { + await loginWithMtls(); + } else if (autoRedirectToKeylessLogin) { + await loginWithKeylessProvider(autoRedirectProviderId); + } else { + await createAccountViaProvider(autoRedirectProviderId); + } } } catch { - if (mounted) setSignupMode({ kind: "anonymous" }); + if (mounted) { + setServerAvailability('unavailable'); + } } })(), { tag: "HomeScreen.loadSignupModeAndAutoRedirect" }); return () => { mounted = false; }; - }, []); + }, [serverCheckNonce]); const createAccount = async () => { try { @@ -114,7 +215,10 @@ function NotAuthenticated() { trackAccountCreated(); } } catch (error) { - await Modal.alert(t('common.error'), t('errors.operationFailed')); + const message = process.env.EXPO_PUBLIC_DEBUG + ? formatOperationFailedDebugMessage(t('errors.operationFailed'), error) + : t('errors.operationFailed'); + await Modal.alert(t('common.error'), message); } } @@ -122,7 +226,9 @@ function NotAuthenticated() { try { const secretBytes = await getRandomBytesAsync(32); const secret = encodeBase64(secretBytes, "base64url"); - await TokenStorage.setPendingExternalAuth({ provider: providerId, secret }); + const snapshot = getActiveServerSnapshot(); + const serverUrl = snapshot.serverUrl ? String(snapshot.serverUrl).trim() : ''; + await TokenStorage.setPendingExternalAuth({ provider: providerId, secret, returnTo: '/', ...(serverUrl ? { serverUrl } : {}) }); const kp = sodium.crypto_sign_seed_keypair(secretBytes); const publicKey = encodeBase64(kp.publicKey); @@ -140,6 +246,17 @@ function NotAuthenticated() { await Modal.alert(t('common.error'), t('errors.operationFailed')); return; } + if (Platform.OS === 'web') { + const location = (globalThis as any)?.window?.location; + if (location && typeof location.assign === 'function') { + location.assign(url); + return; + } + if (location && typeof location.href === 'string') { + location.href = url; + return; + } + } await Linking.openURL(url); } catch (error) { await TokenStorage.clearPendingExternalAuth(); @@ -147,23 +264,184 @@ function NotAuthenticated() { } }; - const signupTitle = - signupMode.kind === "anonymous" - ? t("welcome.createAccount") - : t("welcome.signUpWithProvider", { - provider: getAuthProvider(signupMode.providerId)?.displayName ?? signupMode.providerId, - }); - const signupAction = - signupMode.kind === "anonymous" - ? createAccount - : () => createAccountViaProvider(signupMode.providerId); + const loginWithKeylessProvider = async (providerId: string) => { + try { + const proofBytes = await getRandomBytesAsync(32); + const proof = encodeBase64(proofBytes, "base64url"); + const proofHashBytes = await digest('SHA-256', new TextEncoder().encode(proof)); + const proofHash = encodeHex(proofHashBytes).toLowerCase(); + + const snapshot = getActiveServerSnapshot(); + const serverUrl = snapshot.serverUrl ? String(snapshot.serverUrl).trim() : ''; + await TokenStorage.setPendingExternalAuth({ + provider: providerId, + proof, + mode: 'keyless', + returnTo: '/', + ...(serverUrl ? { serverUrl } : {}), + }); + + const provider = getAuthProvider(providerId); + if (!provider || !provider.getExternalLoginUrl) { + await TokenStorage.clearPendingExternalAuth(); + await Modal.alert(t('common.error'), t('errors.operationFailed')); + return; + } + + const url = await provider.getExternalLoginUrl({ proofHash }); + if (!isSafeExternalAuthUrl(url)) { + await TokenStorage.clearPendingExternalAuth(); + await Modal.alert(t('common.error'), t('errors.operationFailed')); + return; + } + if (Platform.OS === 'web') { + const location = (globalThis as any)?.window?.location; + if (location && typeof location.assign === 'function') { + location.assign(url); + return; + } + if (location && typeof location.href === 'string') { + location.href = url; + return; + } + } + await Linking.openURL(url); + } catch { + await TokenStorage.clearPendingExternalAuth(); + await Modal.alert(t('common.error'), t('errors.operationFailed')); + } + }; + + const loginWithMtls = async () => { + try { + const snapshot = getActiveServerSnapshot(); + const rawServerUrl = snapshot.serverUrl ? String(snapshot.serverUrl).trim() : ""; + const serverUrl = rawServerUrl.replace(/\/+$/, ""); + if (!serverUrl) { + await Modal.alert(t('common.error'), t('errors.operationFailed')); + return; + } + + if (Platform.OS !== 'web') { + const returnTo = 'happier:///mtls'; + const startUrl = `${serverUrl}/v1/auth/mtls/start?returnTo=${encodeURIComponent(returnTo)}`; + await Linking.openURL(startUrl); + return; + } + + const controller = new AbortController(); + const timeoutMs = 15000; + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`${serverUrl}/v1/auth/mtls`, { method: 'POST', signal: controller.signal }); + const json = await res.json().catch(() => null); + if (!res.ok || !json || typeof json.token !== 'string') { + await Modal.alert(t('common.error'), t('errors.operationFailed')); + return; + } + const token = String(json.token); + const credentials = await buildDataKeyCredentialsForToken(token); + await auth.loginWithCredentials(credentials); + } finally { + clearTimeout(timer); + } + } catch (error) { + const message = process.env.EXPO_PUBLIC_DEBUG + ? formatOperationFailedDebugMessage(t('errors.operationFailed'), error) + : t('errors.operationFailed'); + await Modal.alert(t('common.error'), message); + } + }; + + const providerId = signupOptions.preferredProviderId; + const keylessProviderId = loginOptions.preferredKeylessProviderId; + const providerSignupTitle = providerId + ? t("welcome.signUpWithProvider", { + provider: getAuthProvider(providerId)?.displayName ?? providerId, + }) + : ""; + const providerKeylessTitle = keylessProviderId + ? t("welcome.signUpWithProvider", { + provider: getAuthProvider(keylessProviderId)?.displayName ?? keylessProviderId, + }) + : ""; + const anonymousSignupTitle = t("welcome.createAccount"); + + const showProviderSignup = Boolean(providerId); + const showAnonymousSignup = signupOptions.anonymousEnabled; + const showMtlsLogin = loginOptions.mtlsEnabled; + const showKeylessProviderLogin = Boolean(keylessProviderId) && keylessProviderId !== providerId; + const mtlsTitle = t('welcome.signInWithCertificate'); + + const mtlsPrimary = showMtlsLogin && !showProviderSignup && !showAnonymousSignup; + const keylessPrimary = showKeylessProviderLogin && !showProviderSignup && !showAnonymousSignup && !showMtlsLogin; + const primarySignupTitle = mtlsPrimary + ? mtlsTitle + : keylessPrimary + ? providerKeylessTitle + : showProviderSignup + ? providerSignupTitle + : anonymousSignupTitle; + const primarySignupAction = mtlsPrimary + ? loginWithMtls + : keylessPrimary + ? () => loginWithKeylessProvider(keylessProviderId!) + : showProviderSignup + ? () => createAccountViaProvider(providerId!) + : createAccount; const terminalConnectIntentBlock = hasPendingTerminalConnect ? ( - <View style={styles.intentBlock}> + <View testID="welcome-terminal-connect-intent" style={styles.intentBlock}> <Text style={styles.intentTitle}>{t('terminal.connectTerminal')}</Text> <Text style={styles.intentBody}>{t('modals.pleaseSignInFirst')}</Text> </View> ) : null; + const showAuthActions = serverAvailability === 'ready' || serverAvailability === 'legacy'; + + const serverUrlForCopy = (() => { + const snapshot = getActiveServerSnapshot(); + const raw = snapshot?.serverUrl ? String(snapshot.serverUrl).trim() : ''; + return raw || t('status.unknown'); + })(); + + const serverBlockedActions = ( + <> + <View testID="welcome-server-unavailable" style={styles.serverUnavailableBlock}> + <Text style={styles.serverUnavailableTitle}> + {serverAvailability === 'incompatible' ? t('welcome.serverIncompatibleTitle') : t('welcome.serverUnavailableTitle')} + </Text> + <Text style={styles.serverUnavailableBody}> + {serverAvailability === 'incompatible' + ? t('welcome.serverIncompatibleBody', { serverUrl: serverUrlForCopy }) + : t('welcome.serverUnavailableBody', { serverUrl: serverUrlForCopy })} + </Text> + </View> + <View style={styles.buttonContainer}> + <RoundButton + testID="welcome-retry-server" + title={t('common.retry')} + onPress={() => setServerCheckNonce((v) => v + 1)} + /> + </View> + <View style={styles.buttonContainerSecondary}> + <RoundButton + testID="welcome-configure-server" + size="normal" + title={t('server.changeServer')} + onPress={() => router.push('/server')} + display="inverted" + /> + </View> + </> + ); + + const serverLoadingActions = ( + <View style={styles.serverLoadingBlock}> + <ActivityIndicator /> + <Text style={styles.serverLoadingText}>{t('common.loading')}</Text> + </View> + ); + const portraitLayout = ( <View style={styles.portraitContainer}> <Image @@ -178,45 +456,117 @@ function NotAuthenticated() { {t('welcome.subtitle')} </Text> {terminalConnectIntentBlock} + {serverAvailability === 'unavailable' || serverAvailability === 'incompatible' + ? serverBlockedActions + : serverAvailability === 'loading' + ? serverLoadingActions + : null} {Platform.OS !== 'android' && Platform.OS !== 'ios' ? ( <> - <View style={styles.buttonContainer}> - <RoundButton - title={t('welcome.loginWithMobileApp')} - onPress={() => { - trackAccountRestored(); - router.push('/restore'); - }} - /> - </View> - <View style={styles.buttonContainerSecondary}> - <RoundButton - size="normal" - title={signupTitle} - action={signupAction} - display="inverted" - /> - </View> + {showAuthActions && ( + <View style={styles.buttonContainer}> + <RoundButton + testID="welcome-restore" + size="normal" + title={t('welcome.loginWithMobileApp')} + onPress={() => { + trackAccountRestored(); + router.push('/restore'); + }} + /> + </View> + )} + {showAuthActions && showProviderSignup && ( + <View style={styles.buttonContainerSecondary}> + <RoundButton + testID="welcome-signup-provider" + size="normal" + title={providerSignupTitle} + action={() => createAccountViaProvider(providerId!)} + /> + </View> + )} + {showAuthActions && showMtlsLogin && !mtlsPrimary && ( + <View style={styles.buttonContainerSecondary}> + <RoundButton + testID="welcome-mtls-login" + size="normal" + title={mtlsTitle} + action={loginWithMtls} + /> + </View> + )} + {showAuthActions && showAnonymousSignup && ( + <View style={showProviderSignup ? styles.buttonContainerTertiary : styles.buttonContainerSecondary}> + <RoundButton + testID="welcome-create-account" + size="small" + title={anonymousSignupTitle} + action={createAccount} + display="inverted" + /> + </View> + )} + {showAuthActions && !showProviderSignup && !showAnonymousSignup && ( + <View style={styles.buttonContainerSecondary}> + <RoundButton + testID="welcome-create-account" + size="small" + title={primarySignupTitle} + action={primarySignupAction} + display="inverted" + /> + </View> + )} </> ) : ( <> - <View style={styles.buttonContainer}> - <RoundButton - title={signupTitle} - action={signupAction} - /> - </View> - <View style={styles.buttonContainerSecondary}> - <RoundButton - size="normal" - title={t('welcome.linkOrRestoreAccount')} - onPress={() => { - trackAccountRestored(); - router.push('/restore'); - }} - display="inverted" - /> - </View> + {showAuthActions && ( + <View style={styles.buttonContainer}> + <RoundButton + testID={showProviderSignup ? "welcome-signup-provider" : "welcome-create-account"} + size="normal" + title={primarySignupTitle} + action={primarySignupAction} + /> + </View> + )} + {showAuthActions && showMtlsLogin && !mtlsPrimary && ( + <View style={styles.buttonContainerSecondary}> + <RoundButton + testID="welcome-mtls-login" + size="small" + title={mtlsTitle} + action={loginWithMtls} + display="inverted" + /> + </View> + )} + {showAuthActions && showProviderSignup && showAnonymousSignup && ( + <View style={styles.buttonContainerSecondary}> + <RoundButton + testID="welcome-create-account" + size="small" + title={anonymousSignupTitle} + action={createAccount} + display="inverted" + /> + </View> + )} + {showAuthActions && ( + <View style={showProviderSignup && showAnonymousSignup ? styles.buttonContainerTertiary : styles.buttonContainerSecondary}> + <RoundButton + testID="welcome-create-account" + size="small" + title={t('welcome.linkOrRestoreAccount')} + onPress={() => { + trackAccountRestored(); + router.push('/restore'); + }} + display="inverted" + /> + </View> + )} </> )} </View> @@ -240,44 +590,95 @@ function NotAuthenticated() { {t('welcome.subtitle')} </Text> {terminalConnectIntentBlock} + {serverAvailability === 'unavailable' || serverAvailability === 'incompatible' + ? serverBlockedActions + : serverAvailability === 'loading' + ? serverLoadingActions + : null} {Platform.OS !== 'android' && Platform.OS !== 'ios' ? (<> - <View style={styles.landscapeButtonContainer}> - <RoundButton - title={t('welcome.loginWithMobileApp')} - onPress={() => { - trackAccountRestored(); - router.push('/restore'); - }} - /> - </View> - <View style={styles.landscapeButtonContainerSecondary}> - <RoundButton - size="normal" - title={signupTitle} - action={signupAction} - display="inverted" - /> - </View> + {showAuthActions && ( + <View style={styles.landscapeButtonContainer}> + <RoundButton + testID="welcome-restore" + size="normal" + title={t('welcome.loginWithMobileApp')} + onPress={() => { + trackAccountRestored(); + router.push('/restore'); + }} + /> + </View> + )} + {showAuthActions && showProviderSignup && ( + <View style={styles.landscapeButtonContainerSecondary}> + <RoundButton + testID="welcome-signup-provider" + size="normal" + title={providerSignupTitle} + action={() => createAccountViaProvider(providerId!)} + /> + </View> + )} + {showAuthActions && showAnonymousSignup && ( + <View style={showProviderSignup ? styles.landscapeButtonContainerTertiary : styles.landscapeButtonContainerSecondary}> + <RoundButton + testID="welcome-create-account" + size="small" + title={anonymousSignupTitle} + action={createAccount} + display="inverted" + /> + </View> + )} + {showAuthActions && !showProviderSignup && !showAnonymousSignup && ( + <View style={styles.landscapeButtonContainerSecondary}> + <RoundButton + testID="welcome-create-account" + size="small" + title={primarySignupTitle} + action={primarySignupAction} + display="inverted" + /> + </View> + )} </>) : (<> - <View style={styles.landscapeButtonContainer}> - <RoundButton - title={signupTitle} - action={signupAction} - /> - </View> - <View style={styles.landscapeButtonContainerSecondary}> - <RoundButton - size="normal" - title={t('welcome.linkOrRestoreAccount')} - onPress={() => { - trackAccountRestored(); - router.push('/restore'); - }} - display="inverted" - /> - </View> + {showAuthActions && ( + <View style={styles.landscapeButtonContainer}> + <RoundButton + testID={showProviderSignup ? "welcome-signup-provider" : "welcome-create-account"} + size="normal" + title={primarySignupTitle} + action={primarySignupAction} + /> + </View> + )} + {showAuthActions && showProviderSignup && showAnonymousSignup && ( + <View style={styles.landscapeButtonContainerSecondary}> + <RoundButton + testID="welcome-create-account" + size="small" + title={anonymousSignupTitle} + action={createAccount} + display="inverted" + /> + </View> + )} + {showAuthActions && ( + <View style={showProviderSignup && showAnonymousSignup ? styles.landscapeButtonContainerTertiary : styles.landscapeButtonContainerSecondary}> + <RoundButton + testID="welcome-restore" + size="small" + title={t('welcome.linkOrRestoreAccount')} + onPress={() => { + trackAccountRestored(); + router.push('/restore'); + }} + display="inverted" + /> + </View> + )} </>) } </View> @@ -346,12 +747,59 @@ const styles = StyleSheet.create((theme) => ({ textAlign: 'center', lineHeight: 20, }, + serverUnavailableBlock: { + width: '100%', + maxWidth: 560, + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.divider, + backgroundColor: theme.colors.surface, + paddingHorizontal: 14, + paddingVertical: 12, + marginBottom: 20, + }, + serverUnavailableTitle: { + ...Typography.default('semiBold'), + fontSize: 16, + color: theme.colors.text, + textAlign: 'center', + marginBottom: 6, + }, + serverUnavailableBody: { + ...Typography.default(), + fontSize: 14, + color: theme.colors.textSecondary, + textAlign: 'center', + lineHeight: 20, + }, + serverLoadingBlock: { + width: '100%', + maxWidth: 560, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 20, + }, + serverLoadingText: { + ...Typography.default(), + fontSize: 14, + color: theme.colors.textSecondary, + textAlign: 'center', + marginTop: 10, + }, buttonContainer: { - maxWidth: 280, + maxWidth: 320, width: '100%', marginBottom: 16, }, buttonContainerSecondary: { + maxWidth: 320, + width: '100%', + marginBottom: 16, + }, + buttonContainerTertiary: { + maxWidth: 320, + width: '100%', + marginBottom: 0, }, // Landscape styles landscapeContainer: { @@ -398,10 +846,14 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: 16, }, landscapeButtonContainer: { - width: 280, + width: 320, marginBottom: 16, }, landscapeButtonContainerSecondary: { - width: 280, + width: 320, + marginBottom: 16, + }, + landscapeButtonContainerTertiary: { + width: 320, }, })); diff --git a/apps/ui/sources/app/(app)/machine/[id].tsx b/apps/ui/sources/app/(app)/machine/[id]/index.tsx similarity index 91% rename from apps/ui/sources/app/(app)/machine/[id].tsx rename to apps/ui/sources/app/(app)/machine/[id]/index.tsx index b992c8467..ebad1814c 100644 --- a/apps/ui/sources/app/(app)/machine/[id].tsx +++ b/apps/ui/sources/app/(app)/machine/[id]/index.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo, useCallback, useRef } from 'react'; -import { View, Text, ScrollView, ActivityIndicator, RefreshControl, Platform, Pressable, TextInput } from 'react-native'; +import { View, ScrollView, ActivityIndicator, RefreshControl, Platform, Pressable } from 'react-native'; import { useLocalSearchParams, useRouter, Stack } from 'expo-router'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; @@ -23,6 +23,7 @@ import { formatPathRelativeToHome, getSessionName, getSessionSubtitle } from '@/ import { isMachineOnline } from '@/utils/sessions/machineUtils'; import { sync } from '@/sync/sync'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { tryShowDaemonUnavailableAlertForRpcError, tryShowDaemonUnavailableAlertForRpcFailure } from '@/utils/errors/daemonUnavailableAlert'; import { useUnistyles, StyleSheet } from 'react-native-unistyles'; import { t } from '@/text'; import { useNavigateToSession } from '@/hooks/session/useNavigateToSession'; @@ -35,11 +36,12 @@ import { resolveTerminalSpawnOptions } from '@/sync/domains/settings/terminalSet import { resolveWindowsRemoteSessionConsoleFromMachineMetadata } from '@/sync/domains/session/spawn/windowsRemoteSessionConsole'; import { Switch } from '@/components/ui/forms/Switch'; import { CAPABILITIES_REQUEST_MACHINE_DETAILS } from '@/capabilities/requests'; -import { InstallableDepInstaller } from '@/components/machines/InstallableDepInstaller'; -import { getInstallableDepRegistryEntries } from '@/capabilities/installableDepsRegistry'; import { setActiveServerAndSwitch } from '@/sync/domains/server/activeServerSwitch'; import type { DaemonExecutionRunEntry } from '@happier-dev/protocol'; import { ExecutionRunRow } from '@/components/sessions/runs/ExecutionRunRow'; +import { Text, TextInput } from '@/components/ui/text/Text'; +import { useMountedShouldContinue } from '@/hooks/ui/useMountedShouldContinue'; + const styles = StyleSheet.create((theme) => ({ pathInputContainer: { @@ -120,6 +122,7 @@ export default function MachineDetailScreen() { const { theme } = useUnistyles(); const { id: machineId, serverId: serverIdParam } = useLocalSearchParams<{ id: string; serverId?: string }>(); const router = useRouter(); + const shouldContinue = useMountedShouldContinue(); const sessions = useSessions(); const machine = useMachine(machineId!); const navigateToSession = useNavigateToSession(); @@ -321,6 +324,30 @@ export default function MachineDetailScreen() { }, [machine]); const handleStopDaemon = async () => { + const runStopDaemon = async () => { + setIsStoppingDaemon(true); + try { + const result = await machineStopDaemon(machineId!, { serverId: activeServerId }); + Modal.alert(t('machine.daemonStoppedTitle'), result.message); + // Refresh to get updated metadata + await sync.refreshMachines(); + } catch (error) { + const shown = tryShowDaemonUnavailableAlertForRpcError({ + error, + machine, + onRetry: () => { + void runStopDaemon(); + }, + shouldContinue, + }); + if (!shown) { + Modal.alert(t('common.error'), t('machine.stopDaemonFailed')); + } + } finally { + setIsStoppingDaemon(false); + } + }; + // Show confirmation modal using alert with buttons Modal.alert( t('machine.stopDaemonConfirmTitle'), @@ -334,17 +361,7 @@ export default function MachineDetailScreen() { text: t('machine.stopDaemon'), style: 'destructive', onPress: async () => { - setIsStoppingDaemon(true); - try { - const result = await machineStopDaemon(machineId!); - Modal.alert(t('machine.daemonStoppedTitle'), result.message); - // Refresh to get updated metadata - await sync.refreshMachines(); - } catch (error) { - Modal.alert(t('common.error'), t('machine.stopDaemonFailed')); - } finally { - setIsStoppingDaemon(false); - } + await runStopDaemon(); } } ] @@ -415,37 +432,6 @@ export default function MachineDetailScreen() { return snapshot ?? null; }, [detectedCapabilities]); - const installableDepEntries = useMemo(() => { - const entries = getInstallableDepRegistryEntries(); - const results = capabilitiesSnapshot?.response.results; - return entries.map((entry) => { - const enabled = Boolean(machineId && entry.enabledWhen(settings as any)); - const depStatus = entry.getDepStatus(results); - const detectResult = entry.getDetectResult(results); - return { entry, enabled, depStatus, detectResult }; - }); - }, [capabilitiesSnapshot, machineId, settings]); - - React.useEffect(() => { - if (!machineId) return; - if (!isOnline) return; - - const results = capabilitiesSnapshot?.response.results; - if (!results) return; - - const requests = installableDepEntries - .filter((d) => d.enabled) - .filter((d) => d.entry.shouldPrefetchRegistry({ requireExistingResult: true, result: d.detectResult, data: d.depStatus })) - .flatMap((d) => d.entry.buildRegistryDetectRequest().requests ?? []); - - if (requests.length === 0) return; - - refreshDetectedCapabilities({ - request: { requests }, - timeoutMs: 12_000, - }); - }, [capabilitiesSnapshot, installableDepEntries, isOnline, machineId, refreshDetectedCapabilities]); - const detectedClisTitle = useMemo(() => { const headerTextStyle = [ Typography.default('regular'), @@ -701,7 +687,7 @@ export default function MachineDetailScreen() { options={notFoundScreenOptions} /> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> - <Text style={[Typography.default(), { fontSize: 16, color: '#666' }]}> + <Text style={[Typography.default(), { fontSize: 16, color: theme.colors.textSecondary }]}> {t('machine.notFound')} </Text> </View> @@ -914,39 +900,17 @@ export default function MachineDetailScreen() { <DetectedClisList state={detectedCapabilities} /> </ItemGroup> - {installableDepEntries.map(({ entry, enabled, depStatus }) => ( - <InstallableDepInstaller - key={entry.key} - machineId={machineId ?? ''} - serverId={activeServerId} - enabled={enabled} - groupTitle={`${t(entry.groupTitleKey)}${entry.experimental ? ' (experimental)' : ''}`} - depId={entry.depId} - depTitle={entry.depTitle} - depIconName={entry.depIconName as any} - depStatus={depStatus} - capabilitiesStatus={detectedCapabilities.status} - installSpecSettingKey={entry.installSpecSettingKey} - installSpecTitle={entry.installSpecTitle} - installSpecDescription={entry.installSpecDescription} - installLabels={{ - install: t(entry.installLabels.installKey), - update: t(entry.installLabels.updateKey), - reinstall: t(entry.installLabels.reinstallKey), - }} - installModal={{ - installTitle: t(entry.installModal.installTitleKey), - updateTitle: t(entry.installModal.updateTitleKey), - reinstallTitle: t(entry.installModal.reinstallTitleKey), - description: t(entry.installModal.descriptionKey), - }} - refreshStatus={() => void refreshCapabilities()} - refreshRegistry={() => { + <ItemGroup title="Tools"> + <Item + title="Installables" + subtitle="Manage installable tools for this machine." + showChevron={true} + onPress={() => { if (!machineId) return; - refreshDetectedCapabilities({ request: entry.buildRegistryDetectRequest(), timeoutMs: 12_000 }); + router.push(`/machine/${encodeURIComponent(machineId)}/installables?serverId=${encodeURIComponent(activeServerId)}`); }} /> - ))} + </ItemGroup> {/* Daemon */} <ItemGroup title={t('machine.daemon')}> @@ -1074,7 +1038,7 @@ export default function MachineDetailScreen() { subtitle={'Open session'} subtitleStyle={{ color: theme.colors.textSecondary }} onPress={() => navigateToSession(sessionId)} - rightElement={<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />} + rightElement={<Ionicons name="chevron-forward" size={20} color={theme.colors.groupped.chevron} />} /> ); @@ -1094,6 +1058,23 @@ export default function MachineDetailScreen() { if (!machineId) return; if (!canStop) return; setStoppingRunId(run.runId); + const stopSessionProcess = async () => { + const stopResult = await machineStopSession(machineId, run.happySessionId, { serverId: activeServerId }); + if (stopResult.ok) return; + + const shownDaemonUnavailable = tryShowDaemonUnavailableAlertForRpcFailure({ + rpcErrorCode: stopResult.errorCode ?? null, + message: stopResult.error ?? null, + machine, + onRetry: () => { + void stopSessionProcess(); + }, + shouldContinue, + }); + if (!shownDaemonUnavailable) { + Modal.alert(t('common.error'), stopResult.error || 'Failed to stop session'); + } + }; try { const res = await sessionExecutionRunStop( run.happySessionId, @@ -1107,10 +1088,7 @@ export default function MachineDetailScreen() { { confirmText: 'Stop session', cancelText: 'Cancel', destructive: true }, ); if (confirmed) { - const stopResult = await machineStopSession(machineId, run.happySessionId, { serverId: activeServerId }); - if (!stopResult.ok) { - Modal.alert(t('common.error'), stopResult.error || 'Failed to stop session'); - } + await stopSessionProcess(); } else { Modal.alert(t('common.error'), String((res as any).error ?? 'Failed to stop run')); } @@ -1122,10 +1100,7 @@ export default function MachineDetailScreen() { { confirmText: 'Stop session', cancelText: 'Cancel', destructive: true }, ); if (confirmed) { - const stopResult = await machineStopSession(machineId, run.happySessionId, { serverId: activeServerId }); - if (!stopResult.ok) { - Modal.alert(t('common.error'), stopResult.error || 'Failed to stop session'); - } + await stopSessionProcess(); } else { Modal.alert(t('common.error'), error instanceof Error ? error.message : 'Failed to stop run'); } @@ -1157,7 +1132,7 @@ export default function MachineDetailScreen() { {stoppingRunId === run.runId ? ( <ActivityIndicator size="small" color={theme.colors.textSecondary} /> ) : ( - <Ionicons name="stop-circle-outline" size={20} color="#FF9500" /> + <Ionicons name="stop-circle-outline" size={20} color={theme.colors.accent.orange} /> )} </Pressable> ) : null} @@ -1181,7 +1156,7 @@ export default function MachineDetailScreen() { title={getSessionName(session)} subtitle={getSessionSubtitle(session)} onPress={() => navigateToSession(session.id)} - rightElement={<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />} + rightElement={<Ionicons name="chevron-forward" size={20} color={theme.colors.groupped.chevron} />} /> ))} </ItemGroup> diff --git a/apps/ui/sources/app/(app)/machine/[id]/installables.tsx b/apps/ui/sources/app/(app)/machine/[id]/installables.tsx new file mode 100644 index 000000000..c353ffaa4 --- /dev/null +++ b/apps/ui/sources/app/(app)/machine/[id]/installables.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { ScrollView } from 'react-native'; +import { Stack, useLocalSearchParams } from 'expo-router'; + +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { DetectedClisList } from '@/components/machines/DetectedClisList'; +import { InstallableDepInstaller } from '@/components/machines/InstallableDepInstaller'; +import { Switch } from '@/components/ui/forms/Switch'; +import { Modal } from '@/modal'; +import { useMachineCapabilitiesCache } from '@/hooks/server/useMachineCapabilitiesCache'; +import { useMachine, useSettingMutable, useSettings } from '@/sync/domains/state/storage'; +import { isMachineOnline } from '@/utils/sessions/machineUtils'; +import { getActiveServerId } from '@/sync/domains/server/serverProfiles'; +import { CAPABILITIES_REQUEST_MACHINE_DETAILS } from '@/capabilities/requests'; +import { getInstallablesRegistryEntries, type InstallableAutoUpdateMode } from '@/capabilities/installablesRegistry'; +import { resolveInstallablePolicy, applyInstallablePolicyOverride } from '@/sync/domains/settings/installablesPolicy'; +import { useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; + +function formatAutoUpdateMode(mode: InstallableAutoUpdateMode): string { + if (mode === 'off') return 'Off'; + if (mode === 'notify') return 'Notify'; + return 'Auto'; +} + +const MACHINE_INSTALLABLES_SCREEN_OPTIONS = { title: 'Installables' } as const; + +export default function MachineInstallablesScreen() { + const { theme } = useUnistyles(); + const { id: machineId, serverId: serverIdParam } = useLocalSearchParams<{ id: string; serverId?: string }>(); + const machine = useMachine(machineId!); + const isOnline = !!machine && isMachineOnline(machine); + const serverId = typeof serverIdParam === 'string' && serverIdParam.trim().length > 0 ? serverIdParam.trim() : getActiveServerId(); + + const settings = useSettings(); + const [installablesPolicyByMachineId, setInstallablesPolicyByMachineId] = useSettingMutable('installablesPolicyByMachineId'); + + const { state: detectedCapabilities, refresh: refreshDetectedCapabilities } = useMachineCapabilitiesCache({ + machineId: machineId ?? null, + serverId, + enabled: Boolean(machineId && isOnline), + request: CAPABILITIES_REQUEST_MACHINE_DETAILS, + }); + + const capabilitiesSnapshot = React.useMemo(() => { + const snapshot = + detectedCapabilities.status === 'loaded' + ? detectedCapabilities.snapshot + : detectedCapabilities.status === 'loading' + ? detectedCapabilities.snapshot + : detectedCapabilities.status === 'error' + ? detectedCapabilities.snapshot + : undefined; + return snapshot ?? null; + }, [detectedCapabilities]); + + const installables = React.useMemo(() => { + const entries = getInstallablesRegistryEntries(); + const results = capabilitiesSnapshot?.response.results; + return entries.map((entry) => { + const enabled = entry.enabledWhen(settings as any); + const status = entry.getStatus(results); + const detectResult = entry.getDetectResult(results); + const policy = resolveInstallablePolicy({ + settings: settings as any, + machineId: machineId ?? '', + installableKey: entry.key, + defaults: entry.defaultPolicy, + }); + return { entry, enabled, status, detectResult, policy }; + }); + }, [capabilitiesSnapshot, machineId, settings]); + + React.useEffect(() => { + if (!machineId) return; + if (!isOnline) return; + const results = capabilitiesSnapshot?.response.results; + if (!results) return; + + const requests = installables + .filter((d) => d.enabled) + .filter((d) => d.entry.shouldPrefetchRegistry({ requireExistingResult: true, result: d.detectResult, data: d.status })) + .flatMap((d) => d.entry.buildRegistryDetectRequest().requests ?? []); + + if (requests.length === 0) return; + + refreshDetectedCapabilities({ + request: { requests }, + timeoutMs: 12_000, + }); + }, [capabilitiesSnapshot, installables, isOnline, machineId, refreshDetectedCapabilities]); + + const setPolicyPatch = React.useCallback((installableKey: string, patch: { autoInstallWhenNeeded?: boolean; autoUpdateMode?: InstallableAutoUpdateMode }) => { + if (!machineId) return; + const next = applyInstallablePolicyOverride({ prev: installablesPolicyByMachineId ?? {}, machineId, installableKey, patch }); + setInstallablesPolicyByMachineId(next); + }, [installablesPolicyByMachineId, machineId, setInstallablesPolicyByMachineId]); + + return ( + <> + <Stack.Screen options={MACHINE_INSTALLABLES_SCREEN_OPTIONS} /> + <ScrollView + contentContainerStyle={{ paddingBottom: 24 }} + style={{ backgroundColor: theme.colors.groupped.background }} + > + <ItemGroup title="About"> + <Item + title="Installables" + subtitle="Manage tools that Happier can install and keep up to date on this machine." + showChevron={false} + /> + </ItemGroup> + + <ItemGroup title="Detected CLIs"> + <DetectedClisList state={detectedCapabilities} layout="stacked" /> + </ItemGroup> + + {installables.map(({ entry, enabled, status, policy }) => { + if (!enabled) return null; + return ( + <InstallableDepInstaller + key={entry.key} + machineId={machineId ?? ''} + serverId={serverId} + enabled={true} + groupTitle={`${entry.title}${entry.experimental ? ' (experimental)' : ''}`} + depId={entry.capabilityId} + depTitle={entry.title} + depIconName={entry.iconName as any} + depStatus={status} + capabilitiesStatus={detectedCapabilities.status} + extraItems={ + <> + <Item + title="Auto-install when needed" + subtitle="Installs in the background when required for a selected backend (best-effort)." + rightElement={<Switch value={policy.autoInstallWhenNeeded} onValueChange={(next) => setPolicyPatch(entry.key, { autoInstallWhenNeeded: next })} />} + showChevron={false} + onPress={() => setPolicyPatch(entry.key, { autoInstallWhenNeeded: !policy.autoInstallWhenNeeded })} + /> + <Item + title="Auto-update" + subtitle={formatAutoUpdateMode(policy.autoUpdateMode)} + showChevron={true} + onPress={() => { + Modal.alert( + 'Auto-update', + 'Choose how Happier should handle updates for this installable.', + [ + { text: 'Off', onPress: () => setPolicyPatch(entry.key, { autoUpdateMode: 'off' }) }, + { text: 'Notify', onPress: () => setPolicyPatch(entry.key, { autoUpdateMode: 'notify' }) }, + { text: 'Auto', onPress: () => setPolicyPatch(entry.key, { autoUpdateMode: 'auto' }) }, + { text: 'Cancel', style: 'cancel' }, + ], + ); + }} + /> + </> + } + installSpecSettingKey={entry.installSpecSettingKey} + installSpecTitle={entry.installSpecTitle} + installSpecDescription={entry.installSpecDescription} + installLabels={{ + install: t(entry.installLabels.installKey), + update: t(entry.installLabels.updateKey), + reinstall: t(entry.installLabels.reinstallKey), + }} + installModal={{ + installTitle: t(entry.installModal.installTitleKey), + updateTitle: t(entry.installModal.updateTitleKey), + reinstallTitle: t(entry.installModal.reinstallTitleKey), + description: t(entry.installModal.descriptionKey), + }} + refreshStatus={() => refreshDetectedCapabilities()} + refreshRegistry={() => refreshDetectedCapabilities({ request: entry.buildRegistryDetectRequest(), timeoutMs: 12_000 })} + /> + ); + })} + </ScrollView> + </> + ); +} diff --git a/apps/ui/sources/app/(app)/mtls.tsx b/apps/ui/sources/app/(app)/mtls.tsx new file mode 100644 index 000000000..84e3d5afc --- /dev/null +++ b/apps/ui/sources/app/(app)/mtls.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { router, useLocalSearchParams } from 'expo-router'; + +import { useAuth } from '@/auth/context/AuthContext'; +import { getActiveServerSnapshot } from '@/sync/domains/server/serverRuntime'; +import { buildDataKeyCredentialsForToken } from '@/auth/flows/buildDataKeyCredentialsForToken'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { formatOperationFailedDebugMessage } from '@/utils/errors/formatOperationFailedDebugMessage'; + +export default function MtlsCallbackScreen() { + const auth = useAuth(); + const params = useLocalSearchParams(); + + React.useEffect(() => { + let mounted = true; + (async () => { + try { + const code = typeof params.code === 'string' ? params.code : ''; + if (!code.trim()) { + await Modal.alert(t('common.error'), t('errors.operationFailed')); + router.replace('/'); + return; + } + + const snapshot = getActiveServerSnapshot(); + const rawServerUrl = snapshot.serverUrl ? String(snapshot.serverUrl).trim() : ''; + const serverUrl = rawServerUrl.replace(/\/+$/, ''); + if (!serverUrl) { + await Modal.alert(t('common.error'), t('errors.operationFailed')); + router.replace('/'); + return; + } + + const controller = new AbortController(); + const timeoutMs = 15000; + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`${serverUrl}/v1/auth/mtls/claim`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code }), + signal: controller.signal, + }); + const json = await res.json().catch(() => null); + if (!res.ok || !json || typeof json.token !== 'string') { + await Modal.alert(t('common.error'), t('errors.operationFailed')); + router.replace('/'); + return; + } + + const token = String(json.token); + const credentials = await buildDataKeyCredentialsForToken(token); + await auth.loginWithCredentials(credentials); + if (!mounted) return; + router.replace('/'); + } finally { + clearTimeout(timer); + } + } catch (error) { + const message = process.env.EXPO_PUBLIC_DEBUG + ? formatOperationFailedDebugMessage(t('errors.operationFailed'), error) + : t('errors.operationFailed'); + await Modal.alert(t('common.error'), message); + router.replace('/'); + } + })(); + return () => { + mounted = false; + }; + }, [auth, params.code]); + + return ( + <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> + <ActivityIndicator /> + </View> + ); +} + diff --git a/apps/ui/sources/app/(app)/new/pick/machine.tsx b/apps/ui/sources/app/(app)/new/pick/machine.tsx index c36180a97..57024c508 100644 --- a/apps/ui/sources/app/(app)/new/pick/machine.tsx +++ b/apps/ui/sources/app/(app)/new/pick/machine.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pressable, Text, View, Platform } from 'react-native'; +import { Pressable, View, Platform } from 'react-native'; import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { CommonActions } from '@react-navigation/native'; import { Typography } from '@/constants/Typography'; @@ -21,6 +21,8 @@ import { getActiveServerId, listServerProfiles } from '@/sync/domains/server/ser import { resolveActiveServerSelectionFromRawSettings } from '@/sync/domains/server/selection/serverSelectionResolution'; import { useServerScopedMachineOptions } from '@/components/sessions/new/hooks/machines/useServerScopedMachineOptions'; import { isMachineOnline } from '@/utils/sessions/machineUtils'; +import { Text } from '@/components/ui/text/Text'; + function useMachinePickerScreenOptions(params: { title: string; diff --git a/apps/ui/sources/app/(app)/new/pick/path.tsx b/apps/ui/sources/app/(app)/new/pick/path.tsx index 5fbe723b4..05e9ca0a6 100644 --- a/apps/ui/sources/app/(app)/new/pick/path.tsx +++ b/apps/ui/sources/app/(app)/new/pick/path.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo } from 'react'; -import { View, Text, Pressable, Platform } from 'react-native'; +import { View, Pressable, Platform } from 'react-native'; import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { CommonActions } from '@react-navigation/native'; import { Typography } from '@/constants/Typography'; @@ -12,6 +12,8 @@ import { layout } from '@/components/ui/layout/layout'; import { PathSelector } from '@/components/sessions/new/components/PathSelector'; import { SearchHeader } from '@/components/ui/forms/SearchHeader'; import { getRecentPathsForMachine } from '@/utils/sessions/recentPaths'; +import { Text } from '@/components/ui/text/Text'; + export default React.memo(function PathPickerScreen() { const { theme } = useUnistyles(); diff --git a/apps/ui/sources/app/(app)/new/pick/resume.tsx b/apps/ui/sources/app/(app)/new/pick/resume.tsx index 334a88df0..5ccc1ea72 100644 --- a/apps/ui/sources/app/(app)/new/pick/resume.tsx +++ b/apps/ui/sources/app/(app)/new/pick/resume.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Pressable, InteractionManager } from 'react-native'; +import { View, Pressable, InteractionManager } from 'react-native'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native'; import { Ionicons } from '@expo/vector-icons'; @@ -13,6 +13,8 @@ import { MultiTextInput, type MultiTextInputHandle } from '@/components/ui/forms import type { AgentId } from '@/agents/catalog/catalog'; import { DEFAULT_AGENT_ID, getAgentCore, isAgentId } from '@/agents/catalog/catalog'; import { getClipboardStringTrimmedSafe } from '@/utils/ui/clipboard'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme) => ({ container: { diff --git a/apps/ui/sources/app/(app)/oauth/[provider].tsx b/apps/ui/sources/app/(app)/oauth/[provider].tsx index 63b5ac7b6..f66804a9d 100644 --- a/apps/ui/sources/app/(app)/oauth/[provider].tsx +++ b/apps/ui/sources/app/(app)/oauth/[provider].tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { ActivityIndicator, View } from 'react-native'; +import { ActivityIndicator, Pressable, View } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; +import { useUnistyles } from 'react-native-unistyles'; import { useAuth } from '@/auth/context/AuthContext'; import { Modal } from '@/modal'; @@ -14,11 +15,28 @@ import { serverFetch } from '@/sync/http/client'; import { isSessionSharingSupported } from '@/sync/api/capabilities/sessionSharingSupport'; import { getAuthProvider } from '@/auth/providers/registry'; import { buildContentKeyBinding } from '@/auth/oauth/contentKeyBinding'; +import { getActiveServerSnapshot, upsertAndActivateServer } from '@/sync/domains/server/serverRuntime'; +import { Text, TextInput } from '@/components/ui/text/Text'; +import { buildDataKeyCredentialsForToken } from '@/auth/flows/buildDataKeyCredentialsForToken'; + function paramString(params: Record<string, unknown>, key: string): string | null { const value = (params as any)[key]; if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : null; - return typeof value === 'string' ? value : null; + if (typeof value === 'string') return value; + + // Cold-start/hydration on web can temporarily omit search params from expo-router's + // useLocalSearchParams, even though the URL already contains them. Fall back to + // window.location.search so the OAuth return page can still finalize. + try { + const search = (globalThis as any)?.window?.location?.search; + if (typeof search !== 'string' || !search) return null; + const parsed = new URLSearchParams(search.startsWith('?') ? search : `?${search}`); + const fromSearch = parsed.get(key); + return typeof fromSearch === 'string' ? fromSearch : null; + } catch { + return null; + } } function mapUsernameErrorToMessage(code: string): string { @@ -52,32 +70,205 @@ function mapFinalizeErrorToMessage(code: string): string { } } +function tryResolveProviderIdFromWebPathname(): string | null { + try { + const pathname = (globalThis as any)?.window?.location?.pathname; + if (typeof pathname !== 'string' || !pathname.trim()) return null; + const match = pathname.match(/\/oauth\/([^/?#]+)/i); + const provider = match?.[1]?.toString?.().trim?.().toLowerCase?.() ?? ''; + return provider || null; + } catch { + return null; + } +} + +function buildRestoreRedirectUrl(params: { providerId: string; reason: 'provider_already_linked' }): string { + const provider = encodeURIComponent(params.providerId); + const reason = encodeURIComponent(params.reason); + return `/restore?provider=${provider}&reason=${reason}`; +} + +function normalizeInternalReturnTo(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed.startsWith('/')) return null; + if (trimmed.startsWith('//')) return null; + return trimmed; +} + +function maybeActivateServerUrl(rawServerUrl: unknown): void { + const serverUrl = typeof rawServerUrl === 'string' ? rawServerUrl.trim() : ''; + if (!serverUrl) return; + + const active = getActiveServerSnapshot(); + const current = typeof active?.serverUrl === 'string' ? active.serverUrl.trim() : ''; + if (current === serverUrl) return; + + upsertAndActivateServer({ serverUrl, source: 'url', scope: 'tab' }); +} + export default function OAuthProviderReturn() { const router = useRouter(); const params = useLocalSearchParams() as any; const auth = useAuth(); + const { theme } = useUnistyles(); const [busy, setBusy] = React.useState(false); - const handledRef = React.useRef(false); + const [usernameHint, setUsernameHint] = React.useState<string | null>(null); + const [usernameValue, setUsernameValue] = React.useState<string>(''); + const pendingUsernameContextRef = React.useRef<null | Readonly<{ + providerId: string; + providerName: string; + mode: 'keyed' | 'keyless'; + secret: string | null; + proof: string | null; + returnTo: string; + serverUrl?: string; + basePayload: Record<string, unknown>; + }>>(null); + + const resolvedProviderId = + ((paramString(params, 'provider') ?? '').trim().toLowerCase() + || tryResolveProviderIdFromWebPathname() + || '').trim().toLowerCase(); + const resolvedFlow = paramString(params, 'flow'); + const resolvedStatus = paramString(params, 'status'); + const resolvedError = paramString(params, 'error'); + const resolvedPending = paramString(params, 'pending') ?? ''; + const resolvedLogin = paramString(params, 'login') ?? ''; + const resolvedReason = paramString(params, 'reason'); + const resolvedMode = paramString(params, 'mode'); + + const submitUsername = React.useCallback(() => { + const ctx = pendingUsernameContextRef.current; + if (!ctx) { + router.replace('/'); + return; + } + const nextUsername = usernameValue.trim(); + if (!nextUsername) { + setUsernameHint(t('friends.username.invalid')); + return; + } + + fireAndForget((async () => { + setBusy(true); + try { + const base = typeof ctx.serverUrl === 'string' ? ctx.serverUrl.trim().replace(/\/+$/, '') : ''; + const finalizePath = + ctx.mode === 'keyless' + ? `/v1/auth/external/${encodeURIComponent(ctx.providerId)}/finalize-keyless` + : `/v1/auth/external/${encodeURIComponent(ctx.providerId)}/finalize`; + const url = base ? `${base}${finalizePath}` : finalizePath; + const response = await serverFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...ctx.basePayload, username: nextUsername }), + }, { includeAuth: false }); + const json = await response.json().catch(() => ({})); + + if (response.ok && json?.token) { + await TokenStorage.clearPendingExternalAuth(); + pendingUsernameContextRef.current = null; + setUsernameHint(null); + maybeActivateServerUrl(ctx.serverUrl); + if (ctx.mode === 'keyless') { + const credentials = await buildDataKeyCredentialsForToken(json.token); + await (auth as any).loginWithCredentials(credentials); + } else { + await auth.login(json.token, ctx.secret!); + } + router.replace(ctx.returnTo); + return; + } + + const err = typeof json?.error === 'string' ? json.error : 'token-exchange-failed'; + if (err === 'provider-already-linked') { + await TokenStorage.clearPendingExternalAuth(); + pendingUsernameContextRef.current = null; + setUsernameHint(null); + router.replace(buildRestoreRedirectUrl({ providerId: ctx.providerId, reason: 'provider_already_linked' })); + return; + } + if (err === 'restore-required') { + await TokenStorage.clearPendingExternalAuth(); + pendingUsernameContextRef.current = null; + setUsernameHint(null); + router.replace('/restore'); + return; + } + if (err === 'username-taken') { + setUsernameHint(t('friends.username.taken')); + return; + } + if (err === 'invalid-username' || err === 'username-required') { + setUsernameHint(t('friends.username.invalid')); + return; + } + if (err === 'invalid-pending') { + await Modal.alert(t('common.error'), t('errors.oauthStateMismatch')); + await TokenStorage.clearPendingExternalAuth(); + pendingUsernameContextRef.current = null; + setUsernameHint(null); + router.replace('/'); + return; + } + + await Modal.alert(t('common.error'), mapFinalizeErrorToMessage(err)); + await TokenStorage.clearPendingExternalAuth(); + pendingUsernameContextRef.current = null; + setUsernameHint(null); + router.replace('/'); + } finally { + setBusy(false); + } + })(), { tag: 'OAuthProviderReturn.submitUsername' }); + }, [auth, router, usernameValue]); + + const cancelUsername = React.useCallback(() => { + fireAndForget((async () => { + await TokenStorage.clearPendingExternalAuth(); + })(), { tag: 'OAuthProviderReturn.cancelUsername' }); + pendingUsernameContextRef.current = null; + setUsernameHint(null); + setUsernameValue(''); + router.replace('/'); + }, [router]); React.useEffect(() => { - if (handledRef.current) return; - handledRef.current = true; + const providerId = resolvedProviderId; + const flow = resolvedFlow; + const status = resolvedStatus; + const error = resolvedError; + const pendingFromParams = resolvedPending; + const loginFromParams = resolvedLogin; + const reasonFromParams = resolvedReason; + const loginFn = auth.login; + const credentialsFromAuth = auth.credentials; + + let disposed = false; + const controller = new AbortController(); + const isAbort = (e: unknown) => { + if (controller.signal.aborted) return true; + const name = (e as any)?.name; + return typeof name === 'string' && name.toLowerCase() === 'aborterror'; + }; - let cancelled = false; const safeSetBusy = (value: boolean) => { - if (!cancelled) setBusy(value); + if (disposed || controller.signal.aborted) return; + setBusy(value); }; const safeReplace = (path: string) => { - if (!cancelled) router.replace(path); + if (disposed || controller.signal.aborted) return; + router.replace(path); }; fireAndForget((async () => { - const providerId = (paramString(params, 'provider') ?? '').trim().toLowerCase(); if (!providerId) { safeReplace('/'); return; } + const provider = getAuthProvider(providerId); if (!provider) { await Modal.alert(t('common.error'), t('errors.oauthInitializationFailed')); @@ -85,9 +276,6 @@ export default function OAuthProviderReturn() { return; } - const flow = paramString(params, 'flow'); - const status = paramString(params, 'status'); - const error = paramString(params, 'error'); if (error) { const providerName = provider.displayName ?? providerId; const message = @@ -105,44 +293,67 @@ export default function OAuthProviderReturn() { } if (flow === 'auth') { - const pending = paramString(params, 'pending') ?? ''; + const pending = pendingFromParams; const state = await TokenStorage.getPendingExternalAuth(); - if (!pending || !state || state.provider !== providerId || !state.secret) { + const keyless = + (resolvedMode ?? '').toString().trim().toLowerCase() === 'keyless' + || state?.mode === 'keyless'; + const secret = typeof state?.secret === 'string' ? state.secret : null; + const proof = typeof state?.proof === 'string' ? state.proof : null; + + if (!pending || !state || state.provider !== providerId || (keyless ? !proof : !secret)) { await Modal.alert(t('common.error'), t('errors.oauthInitializationFailed')); safeReplace('/'); return; } + const returnTo = normalizeInternalReturnTo(state.returnTo) ?? '/'; try { - const secretBytes = decodeBase64(state.secret, 'base64url'); - const { challenge, signature, publicKey } = authChallenge(secretBytes); - - const body: any = { - pending, - publicKey: encodeBase64(publicKey), - challenge: encodeBase64(challenge), - signature: encodeBase64(signature), - }; - if (state.intent === 'reset') { - body.reset = true; - } - - const supportsSharing = await isSessionSharingSupported({ timeoutMs: 800 }); - if (supportsSharing) { - const binding = await buildContentKeyBinding(secretBytes); - body.contentPublicKey = binding.contentPublicKey; - body.contentPublicKeySig = binding.contentPublicKeySig; + const body: any = keyless + ? { + pending, + proof, + } + : (() => { + const secretBytes = decodeBase64(secret!, 'base64url'); + const { challenge, signature, publicKey } = authChallenge(secretBytes); + const keyedBody: any = { + pending, + publicKey: encodeBase64(publicKey), + challenge: encodeBase64(challenge), + signature: encodeBase64(signature), + }; + if (state.intent === 'reset') { + keyedBody.reset = true; + } + return keyedBody; + })(); + + if (!keyless) { + const secretBytes = decodeBase64(secret!, 'base64url'); + const supportsSharing = await isSessionSharingSupported({ timeoutMs: 800 }); + if (supportsSharing) { + const binding = await buildContentKeyBinding(secretBytes); + body.contentPublicKey = binding.contentPublicKey; + body.contentPublicKeySig = binding.contentPublicKeySig; + } } - const login = paramString(params, 'login') ?? ''; - const reason = paramString(params, 'reason'); + const login = loginFromParams; + const reason = reasonFromParams; const finalize = async (payload: any) => { safeSetBusy(true); try { - const response = await serverFetch(`/v1/auth/external/${encodeURIComponent(providerId)}/finalize`, { + const base = typeof state.serverUrl === 'string' ? state.serverUrl.trim().replace(/\/+$/, '') : ''; + const finalizePath = keyless + ? `/v1/auth/external/${encodeURIComponent(providerId)}/finalize-keyless` + : `/v1/auth/external/${encodeURIComponent(providerId)}/finalize`; + const url = base ? `${base}${finalizePath}` : finalizePath; + const response = await serverFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, body: JSON.stringify(payload), }, { includeAuth: false }); const json = await response.json().catch(() => ({})); @@ -150,81 +361,22 @@ export default function OAuthProviderReturn() { } finally { safeSetBusy(false); } - }; - - const promptForUsername = async (params: { hint: string; defaultValue?: string }) => { - return await Modal.prompt( - t('profile.username'), - params.hint, - { - placeholder: t('profile.username'), - defaultValue: params.defaultValue, - confirmText: t('common.save'), - cancelText: t('common.cancel'), - }, - ); - }; - - const runUsernameLoop = async (params: { initialHint: string; initialDefaultValue?: string }) => { - let hint = params.initialHint; - let defaultValue = params.initialDefaultValue; - - while (true) { - const username = await promptForUsername({ hint, defaultValue }); - if (username == null) { - await TokenStorage.clearPendingExternalAuth(); - safeReplace('/'); - return; - } - - const res = await finalize({ ...body, username }); - if (res.ok && res.json?.token) { - await TokenStorage.clearPendingExternalAuth(); - if (cancelled) return; - await auth.login(res.json.token, state.secret); - if (cancelled) return; - safeReplace('/friends'); - return; - } - - const err = - typeof res.json?.error === 'string' - ? res.json.error - : 'token-exchange-failed'; - if (err === 'provider-already-linked') { - const providerName = provider.displayName ?? providerId; - await Modal.alert(t('common.error'), t('errors.providerAlreadyLinked', { provider: providerName })); - await TokenStorage.clearPendingExternalAuth(); - safeReplace('/restore'); - return; - } - if (err === 'username-taken') { - hint = t('friends.username.taken'); - defaultValue = username; - continue; - } - if (err === 'invalid-username' || err === 'username-required') { - hint = t('friends.username.invalid'); - defaultValue = username; - continue; - } - if (err === 'invalid-pending') { - await Modal.alert(t('common.error'), t('errors.oauthStateMismatch')); - await TokenStorage.clearPendingExternalAuth(); - safeReplace('/'); - return; - } - - await Modal.alert(t('common.error'), mapFinalizeErrorToMessage(err)); - await TokenStorage.clearPendingExternalAuth(); - safeReplace('/'); - return; - } - }; + }; if (status === 'username_required') { const initialHint = reason === 'invalid_login' ? t('friends.username.invalid') : t('friends.username.taken'); - await runUsernameLoop({ initialHint, initialDefaultValue: login || undefined }); + pendingUsernameContextRef.current = { + providerId, + providerName: provider.displayName ?? providerId, + mode: keyless ? 'keyless' : 'keyed', + secret, + proof, + returnTo, + serverUrl: state.serverUrl, + basePayload: body, + }; + setUsernameHint(initialHint); + setUsernameValue(login || ''); return; } @@ -236,14 +388,29 @@ export default function OAuthProviderReturn() { : 'token-exchange-failed'; if (err === 'provider-already-linked') { const providerName = provider.displayName ?? providerId; - await Modal.alert(t('common.error'), t('errors.providerAlreadyLinked', { provider: providerName })); + await TokenStorage.clearPendingExternalAuth(); + safeReplace(buildRestoreRedirectUrl({ providerId, reason: 'provider_already_linked' })); + return; + } + if (err === 'restore-required') { await TokenStorage.clearPendingExternalAuth(); safeReplace('/restore'); return; } if (err === 'username-required' || err === 'username-taken') { const initialHint = err === 'username-taken' ? t('friends.username.taken') : t('friends.username.invalid'); - await runUsernameLoop({ initialHint, initialDefaultValue: login || undefined }); + pendingUsernameContextRef.current = { + providerId, + providerName: provider.displayName ?? providerId, + mode: keyless ? 'keyless' : 'keyed', + secret, + proof, + returnTo, + serverUrl: state.serverUrl, + basePayload: body, + }; + setUsernameHint(initialHint); + setUsernameValue(login || ''); return; } @@ -254,18 +421,25 @@ export default function OAuthProviderReturn() { } await TokenStorage.clearPendingExternalAuth(); - if (cancelled) return; - await auth.login(res.json.token, state.secret); - if (cancelled) return; - safeReplace('/friends'); + maybeActivateServerUrl(state.serverUrl); + if (keyless) { + const credentials = await buildDataKeyCredentialsForToken(res.json.token); + await (auth as any).loginWithCredentials(credentials); + } else { + await loginFn(res.json.token, state.secret!); + } + safeReplace(returnTo); return; + } catch (e) { + if (isAbort(e)) return; + throw e; } finally { safeSetBusy(false); } } // connect flow (default) - const credentials = auth.credentials; + const credentials = credentialsFromAuth; const pendingConnect = await TokenStorage.getPendingExternalConnect(); const connectReturnTo = pendingConnect && pendingConnect.provider === providerId && typeof pendingConnect.returnTo === 'string' && pendingConnect.returnTo.trim().startsWith('/') @@ -357,9 +531,84 @@ export default function OAuthProviderReturn() { })(), { tag: 'OAuthProviderReturn.handleRedirect' }); return () => { - cancelled = true; + disposed = true; + controller.abort('oauth-return-disposed'); }; - }, [auth, params, router]); + // Keep deps primitive so we don't dispose mid-flight due to param identity changes. + }, [ + router, + resolvedProviderId, + resolvedFlow, + resolvedStatus, + resolvedError, + resolvedPending, + resolvedLogin, + resolvedReason, + auth.login, + resolvedFlow === 'auth' ? '' : auth.credentials?.token ?? '', + ]); + + if (usernameHint != null) { + return ( + <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }}> + <View style={{ width: '100%', maxWidth: 420 }}> + <Text style={{ fontSize: 18, marginBottom: 8, color: theme.colors.text }}>{t('profile.username')}</Text> + <Text style={{ fontSize: 14, marginBottom: 16, color: theme.colors.textSecondary }}>{usernameHint}</Text> + <TextInput + testID="oauth-username-input" + value={usernameValue} + onChangeText={setUsernameValue} + autoCapitalize="none" + autoCorrect={false} + placeholder={t('profile.username')} + placeholderTextColor={theme.colors.input.placeholder} + style={{ + borderWidth: 1, + borderColor: theme.colors.divider, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + marginBottom: 12, + backgroundColor: theme.colors.input.background, + color: theme.colors.input.text, + }} + /> + <View style={{ flexDirection: 'row', gap: 12 }}> + <Pressable + testID="oauth-username-cancel" + onPress={cancelUsername} + style={{ + flex: 1, + paddingVertical: 10, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.colors.divider, + alignItems: 'center', + justifyContent: 'center', + }} + > + <Text style={{ color: theme.colors.text }}>{t('common.cancel')}</Text> + </Pressable> + <Pressable + testID="oauth-username-save" + onPress={submitUsername} + style={{ + flex: 1, + paddingVertical: 10, + borderRadius: 8, + backgroundColor: theme.colors.button.primary.background, + alignItems: 'center', + justifyContent: 'center', + }} + > + <Text style={{ color: theme.colors.button.primary.tint }}>{t('common.save')}</Text> + </Pressable> + </View> + </View> + {busy ? <ActivityIndicator size="small" style={{ marginTop: 16 }} /> : null} + </View> + ); + } return ( <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> diff --git a/apps/ui/sources/app/(app)/oauth/oauthReturn.keyless.spec.tsx b/apps/ui/sources/app/(app)/oauth/oauthReturn.keyless.spec.tsx new file mode 100644 index 000000000..81494870d --- /dev/null +++ b/apps/ui/sources/app/(app)/oauth/oauthReturn.keyless.spec.tsx @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + clearPendingExternalAuthMock, + flushOAuthEffects, + localSearchParamsMock, + loginWithCredentialsSpy, + replaceSpy, + resetOAuthHarness, + runWithOAuthScreen, + setPendingExternalAuthState, +} from '@/auth/providers/github/test/oauthReturnHarness'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@shopify/react-native-skia', () => ({})); + +afterEach(() => { + vi.unstubAllGlobals(); + resetOAuthHarness(); +}); + +describe('oauth/[provider] return (keyless)', () => { + it('finalizes keyless oauth auth and logs in with data-key credentials', async () => { + replaceSpy.mockReset(); + loginWithCredentialsSpy.mockReset(); + clearPendingExternalAuthMock.mockReset(); + + localSearchParamsMock.mockReturnValue({ + provider: 'github', + flow: 'auth', + mode: 'keyless', + pending: 'p1', + }); + setPendingExternalAuthState({ provider: 'github', proof: 'proof_1', mode: 'keyless' }); + + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (url: any, init?: any) => { + if (typeof url === 'string' && url.includes('/v1/auth/external/github/finalize-keyless')) { + const body = JSON.parse(String(init?.body ?? '{}')); + if (body?.pending !== 'p1' || body?.proof !== 'proof_1') { + return new Response(JSON.stringify({ error: 'invalid' }), { status: 400 }); + } + return new Response(JSON.stringify({ success: true, token: 'tok_1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + + await runWithOAuthScreen(async () => { + await flushOAuthEffects(); + expect(fetchMock).toHaveBeenCalled(); + expect(clearPendingExternalAuthMock).toHaveBeenCalled(); + expect(loginWithCredentialsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'tok_1', + encryption: expect.objectContaining({ + publicKey: expect.any(String), + machineKey: expect.any(String), + }), + }), + ); + expect(replaceSpy).toHaveBeenCalledWith('/'); + }); + + vi.stubGlobal('fetch', originalFetch); + }); + + it('redirects to /restore when keyless finalize returns restore-required for a keyed account', async () => { + replaceSpy.mockReset(); + loginWithCredentialsSpy.mockReset(); + clearPendingExternalAuthMock.mockReset(); + + localSearchParamsMock.mockReturnValue({ + provider: 'github', + flow: 'auth', + mode: 'keyless', + pending: 'p2', + }); + setPendingExternalAuthState({ provider: 'github', proof: 'proof_2', mode: 'keyless' }); + + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (url: any) => { + if (typeof url === 'string' && url.includes('/v1/auth/external/github/finalize-keyless')) { + return new Response(JSON.stringify({ error: 'restore-required' }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + + await runWithOAuthScreen(async () => { + await flushOAuthEffects(); + expect(fetchMock).toHaveBeenCalled(); + expect(clearPendingExternalAuthMock).toHaveBeenCalled(); + expect(loginWithCredentialsSpy).not.toHaveBeenCalled(); + expect(replaceSpy).toHaveBeenCalledWith('/restore'); + }); + + vi.stubGlobal('fetch', originalFetch); + }); +}); diff --git a/apps/ui/sources/app/(app)/oauth/oauthReturn.providerAlreadyLinked.spec.tsx b/apps/ui/sources/app/(app)/oauth/oauthReturn.providerAlreadyLinked.spec.tsx index caae04d78..020bd253c 100644 --- a/apps/ui/sources/app/(app)/oauth/oauthReturn.providerAlreadyLinked.spec.tsx +++ b/apps/ui/sources/app/(app)/oauth/oauthReturn.providerAlreadyLinked.spec.tsx @@ -40,8 +40,8 @@ describe('oauth/[provider] return', () => { await runWithOAuthScreen(async () => { await flushOAuthEffects(); - expect(modal.alert).toHaveBeenCalledTimes(1); - expect(replaceSpy).toHaveBeenCalledWith('/restore'); + expect(modal.alert).toHaveBeenCalledTimes(0); + expect(replaceSpy).toHaveBeenCalledWith('/restore?provider=github&reason=provider_already_linked'); expect(loginSpy).not.toHaveBeenCalled(); }); vi.stubGlobal('fetch', originalFetch); diff --git a/apps/ui/sources/app/(app)/restore/index.spec.tsx b/apps/ui/sources/app/(app)/restore/index.spec.tsx new file mode 100644 index 000000000..db200da10 --- /dev/null +++ b/apps/ui/sources/app/(app)/restore/index.spec.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +type ReactActEnvironmentGlobal = typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +(globalThis as ReactActEnvironmentGlobal).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native-reanimated', () => ({})); + +const pushSpy = vi.fn(); +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: vi.fn(), push: pushSpy, replace: vi.fn() }), + useLocalSearchParams: () => ({ + provider: 'github', + reason: 'provider_already_linked', + }), +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + ScrollView: 'ScrollView', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'web', select: (options: any) => options?.web ?? options?.default ?? options?.ios ?? options?.android }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, +})); + +vi.mock('@/auth/context/AuthContext', () => ({ + useAuth: () => ({ login: vi.fn(async () => {}) }), +})); + +vi.mock('@/auth/providers/registry', () => ({ + getAuthProvider: () => ({ + id: 'github', + displayName: 'GitHub', + getRestoreRedirectNotice: () => ({ + title: 'GitHub verified', + body: 'Restore your account key to finish signing in.', + }), + }), +})); + +vi.mock('@/components/ui/buttons/RoundButton', () => ({ + RoundButton: 'RoundButton', +})); + +vi.mock('@/components/ui/layout/layout', () => ({ + layout: { maxWidth: 1024 }, +})); + +vi.mock('@/auth/flows/qrStart', () => ({ + generateAuthKeyPair: () => ({ publicKey: new Uint8Array([1]), secretKey: new Uint8Array([2]) }), + authQRStart: vi.fn(async () => false), +})); + +vi.mock('@/auth/flows/qrWait', () => ({ + authQRWait: vi.fn(async () => null), +})); + +vi.mock('@/encryption/base64', () => ({ + encodeBase64: () => 'x', +})); + +vi.mock('@/components/qr/QRCode', () => ({ + QRCode: 'QRCode', +})); + +vi.mock('@/modal', () => ({ + Modal: { + alert: vi.fn(async () => {}), + }, +})); + +vi.mock('@/sync/api/capabilities/getReadyServerFeatures', () => ({ + getReadyServerFeatures: vi.fn(async () => null), +})); + +vi.mock('@/utils/system/fireAndForget', () => ({ + fireAndForget: (promise: Promise<unknown>) => { + void promise; + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + surface: '#fff', + text: '#000', + textSecondary: '#666', + }, + }, + }), + StyleSheet: { create: (styles: any) => styles }, +})); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function textContent(node: renderer.ReactTestInstance): string { + const c = node.props?.children; + if (typeof c === 'string') return c; + if (Array.isArray(c)) return c.map((x) => (typeof x === 'string' ? x : '')).join(''); + return ''; +} + +describe('/restore', () => { + it('shows provider-specific restore notice when redirected after external auth', async () => { + vi.resetModules(); + const { default: Screen } = await import('./index'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + if (!tree) throw new Error('Expected renderer'); + + const texts = tree.root.findAll((node) => (node.type as unknown) === 'Text'); + const joined = texts.map(textContent).join('\n'); + + expect(joined).toContain('GitHub verified'); + expect(joined).toContain('Restore your account key to finish signing in.'); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); +}); diff --git a/apps/ui/sources/app/(app)/restore/index.tsx b/apps/ui/sources/app/(app)/restore/index.tsx index e5a413a39..bd7f47243 100644 --- a/apps/ui/sources/app/(app)/restore/index.tsx +++ b/apps/ui/sources/app/(app)/restore/index.tsx @@ -1,19 +1,22 @@ import React, { useState, useEffect, useRef } from 'react'; -import { View, Text, TextInput, ScrollView, ActivityIndicator } from 'react-native'; -import { useRouter } from 'expo-router'; +import { View, ScrollView, ActivityIndicator } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { useAuth } from '@/auth/context/AuthContext'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; import { Typography } from '@/constants/Typography'; import { encodeBase64 } from '@/encryption/base64'; import { generateAuthKeyPair, authQRStart } from '@/auth/flows/qrStart'; import { authQRWait } from '@/auth/flows/qrWait'; -import { layout } from '@/components/ui/layout/layout'; import { Modal } from '@/modal'; import { t } from '@/text'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { QRCode } from '@/components/qr/QRCode'; import { getReadyServerFeatures } from '@/sync/api/capabilities/getReadyServerFeatures'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { getAuthProvider } from '@/auth/providers/registry'; +import type { RestoreRedirectReason, RestoreRedirectNotice } from '@/auth/providers/types'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme) => ({ scrollView: { @@ -27,30 +30,56 @@ const stylesheet = StyleSheet.create((theme) => ({ }, contentWrapper: { width: '100%', - maxWidth: layout.maxWidth, - paddingVertical: 24, + maxWidth: 560, + paddingVertical: 28, }, - instructionText: { - fontSize: 20, - color: theme.colors.text, - marginBottom: 24, - ...Typography.default(), + noticeCard: { + borderWidth: 1, + borderColor: theme.colors.divider, + borderRadius: 14, + paddingHorizontal: 16, + paddingVertical: 14, + backgroundColor: theme.colors.surface, }, - secondInstructionText: { + noticeTitle: { fontSize: 16, + color: theme.colors.text, + marginBottom: 6, + ...Typography.default('semiBold'), + }, + noticeBody: { + fontSize: 14, color: theme.colors.textSecondary, - marginBottom: 20, - marginTop: 30, + lineHeight: 20, ...Typography.default(), }, - qrInstructions: { - fontSize: 14, + sectionLead: { + fontSize: 15, color: theme.colors.textSecondary, - marginBottom: 16, - lineHeight: 22, + marginTop: 18, + marginBottom: 14, textAlign: 'center', + lineHeight: 21, ...Typography.default(), }, + qrBlock: { + alignItems: 'center', + justifyContent: 'center', + width: '100%', + paddingVertical: 10, + }, + footer: { + marginTop: 18, + alignItems: 'center', + width: '100%', + }, + footerButton: { + width: '100%', + maxWidth: 320, + }, + footerButtonSpacer: { + height: 12, + }, textInput: { backgroundColor: theme.colors.input.background, padding: 16, @@ -64,11 +93,24 @@ const stylesheet = StyleSheet.create((theme) => ({ }, })); +function paramString(params: Record<string, unknown>, key: string): string | null { + const value = (params as any)[key]; + if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : null; + return typeof value === 'string' ? value : null; +} + +function parseRestoreRedirectReason(value: unknown): RestoreRedirectReason | null { + const raw = typeof value === 'string' ? value.trim() : ''; + if (raw === 'provider_already_linked') return raw; + return null; +} + export default function Restore() { const { theme } = useUnistyles(); const styles = stylesheet; const auth = useAuth(); const router = useRouter(); + const params = useLocalSearchParams() as any; const [restoreKey, setRestoreKey] = useState(''); const [isWaitingForAuth, setIsWaitingForAuth] = useState(false); const [authReady, setAuthReady] = useState(false); @@ -76,6 +118,16 @@ export default function Restore() { const [providerResetEnabled, setProviderResetEnabled] = useState(false); const isCancelledRef = useRef(false); + const restoreRedirectNotice: RestoreRedirectNotice | null = React.useMemo(() => { + const providerId = (paramString(params, 'provider') ?? '').trim().toLowerCase(); + const reason = parseRestoreRedirectReason(paramString(params, 'reason')); + if (!providerId || !reason) return null; + + const provider = getAuthProvider(providerId); + if (!provider?.getRestoreRedirectNotice) return null; + return provider.getRestoreRedirectNotice({ reason }); + }, [params]); + // Memoize keypair generation to prevent re-creating on re-renders const keypair = React.useMemo(() => generateAuthKeyPair(), []); @@ -127,7 +179,6 @@ export default function Restore() { } catch (error) { if (!isCancelledRef.current) { - console.error('QR Auth error:', error); Modal.alert(t('common.error'), t('errors.authenticationFailed')); } } finally { @@ -149,39 +200,56 @@ export default function Restore() { return ( <ScrollView style={styles.scrollView} contentContainerStyle={{ flexGrow: 1 }}> <View style={styles.container}> + <View style={styles.contentWrapper}> + {restoreRedirectNotice ? ( + <View style={styles.noticeCard}> + <Text style={styles.noticeTitle}>{restoreRedirectNotice.title}</Text> + <Text style={styles.noticeBody}>{restoreRedirectNotice.body}</Text> + </View> + ) : null} - <View style={{justifyContent: 'flex-end' }}> - <Text style={styles.secondInstructionText}> - {t('connect.restoreQrInstructions')} - </Text> - </View> - {!authReady && ( - <View style={{ width: 200, height: 200, backgroundColor: theme.colors.surface, alignItems: 'center', justifyContent: 'center' }}> - <ActivityIndicator size="small" color={theme.colors.text} /> + <Text style={styles.sectionLead}>{t('connect.restoreQrInstructions')}</Text> + + <View style={styles.qrBlock}> + {!authReady ? ( + <View style={{ width: 220, height: 220, alignItems: 'center', justifyContent: 'center' }}> + <ActivityIndicator size="small" color={theme.colors.text} /> + </View> + ) : ( + <QRCode + data={'happier:///account?' + encodeBase64(keypair.publicKey, 'base64url')} + size={260} + foregroundColor={'black'} + backgroundColor={'white'} + /> + )} </View> - )} - {authReady && ( - <QRCode - data={'happier:///account?' + encodeBase64(keypair.publicKey, 'base64url')} - size={300} - foregroundColor={'black'} - backgroundColor={'white'} - /> - )} - <View style={{ flexGrow: 4, paddingTop: 30 }}> - <RoundButton title={t('connect.restoreWithSecretKeyInstead')} display='inverted' onPress={() => { - router.push('/restore/manual'); - }} /> - {providerResetEnabled ? ( - <View style={{ paddingTop: 12 }}> + + <View style={styles.footer}> + <View style={styles.footerButton}> <RoundButton - size="small" - title={t('connect.lostAccessLink')} + testID="restore-open-manual" + size="normal" + title={t('connect.restoreWithSecretKeyInstead')} display="inverted" - onPress={() => router.push('/restore/lost-access')} + onPress={() => router.push('/restore/manual')} /> </View> - ) : null} + {providerResetEnabled ? ( + <> + <View style={styles.footerButtonSpacer} /> + <View style={styles.footerButton}> + <RoundButton + testID="restore-open-lost-access" + size="small" + title={t('connect.lostAccessLink')} + display="inverted" + onPress={() => router.push('/restore/lost-access')} + /> + </View> + </> + ) : null} + </View> </View> </View> </ScrollView> diff --git a/apps/ui/sources/app/(app)/restore/lost-access.spec.tsx b/apps/ui/sources/app/(app)/restore/lost-access.spec.tsx index 86ec2166c..ae8b1c4d7 100644 --- a/apps/ui/sources/app/(app)/restore/lost-access.spec.tsx +++ b/apps/ui/sources/app/(app)/restore/lost-access.spec.tsx @@ -13,6 +13,10 @@ vi.mock('react-native-reanimated', () => ({})); const canOpenURL = vi.fn(async () => true); const openURL = vi.fn(async () => true); vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, + Dimensions: { get: () => ({ width: 800, height: 600 }) }, + ScrollView: 'ScrollView', View: 'View', Text: 'Text', ActivityIndicator: 'ActivityIndicator', @@ -34,6 +38,15 @@ vi.mock('@/modal', () => ({ }, })); +vi.mock('@/sync/domains/server/serverRuntime', () => ({ + getActiveServerSnapshot: () => ({ + serverId: 'server-a', + serverUrl: 'http://localhost:53288', + kind: 'custom', + generation: 1, + }), +})); + const setPendingExternalAuth = vi.fn(async () => true); const clearPendingExternalAuth = vi.fn(async () => true); vi.mock('@/auth/storage/tokenStorage', () => ({ @@ -49,7 +62,10 @@ vi.mock('@/platform/cryptoRandom', () => ({ })); vi.mock('@/encryption/base64', () => ({ - encodeBase64: () => 'x', + encodeBase64: (_bytes: unknown, encoding?: 'base64' | 'base64url') => { + if (encoding === 'base64url') return 'base64url-value'; + return 'base64-value+slash/plus+'; + }, })); vi.mock('@/encryption/libsodium.lib', () => ({ @@ -58,11 +74,12 @@ vi.mock('@/encryption/libsodium.lib', () => ({ }, })); +const getExternalSignupUrl = vi.fn(async (_params: unknown) => 'https://example.test/oauth'); vi.mock('@/auth/providers/registry', () => ({ getAuthProvider: () => ({ id: 'github', displayName: 'GitHub', - getExternalSignupUrl: async () => 'https://example.test/oauth', + getExternalSignupUrl, }), })); @@ -119,6 +136,9 @@ describe('/restore/lost-access', () => { }); expect(setPendingExternalAuth).toHaveBeenCalledWith(expect.objectContaining({ provider: 'github', intent: 'reset' })); + expect(getExternalSignupUrl).toHaveBeenCalledWith( + expect.objectContaining({ publicKey: 'base64-value+slash/plus+' }), + ); expect(canOpenURL).toHaveBeenCalledWith('https://example.test/oauth'); expect(openURL).toHaveBeenCalledWith('https://example.test/oauth'); } finally { @@ -139,7 +159,7 @@ describe('/restore/lost-access', () => { getAuthProvider: () => ({ id: 'github', displayName: 'GitHub', - getExternalSignupUrl: async () => 'javascript:alert(1)', + getExternalSignupUrl: vi.fn(async () => 'javascript:alert(1)'), }), })); diff --git a/apps/ui/sources/app/(app)/restore/lost-access.tsx b/apps/ui/sources/app/(app)/restore/lost-access.tsx index 770f28ff4..6b90fc9ff 100644 --- a/apps/ui/sources/app/(app)/restore/lost-access.tsx +++ b/apps/ui/sources/app/(app)/restore/lost-access.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ActivityIndicator, Linking, Text, View } from 'react-native'; +import { ActivityIndicator, Linking, Platform, ScrollView, View } from 'react-native'; import { useRouter } from 'expo-router'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; @@ -13,11 +13,22 @@ import { getAuthProvider } from '@/auth/providers/registry'; import { Modal } from '@/modal'; import { isSafeExternalAuthUrl } from '@/auth/providers/externalAuthUrl'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { StyleSheet } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { layout } from '@/components/ui/layout/layout'; +import { useUnistyles } from 'react-native-unistyles'; +import { formatOperationFailedDebugMessage } from '@/utils/errors/formatOperationFailedDebugMessage'; +import { getActiveServerSnapshot } from '@/sync/domains/server/serverRuntime'; +import { Text } from '@/components/ui/text/Text'; + export default function LostAccess() { + useUnistyles(); const router = useRouter(); const [providers, setProviders] = React.useState<string[] | null>(null); + const styles = stylesheet; + React.useEffect(() => { let mounted = true; fireAndForget((async () => { @@ -54,28 +65,51 @@ export default function LostAccess() { try { const secretBytes = await getRandomBytesAsync(32); const secret = encodeBase64(secretBytes, 'base64url'); - await TokenStorage.setPendingExternalAuth({ provider: providerId, secret, intent: 'reset' }); + const snapshot = getActiveServerSnapshot(); + const serverUrl = snapshot.serverUrl ? String(snapshot.serverUrl).trim() : ''; + await TokenStorage.setPendingExternalAuth({ + provider: providerId, + secret, + intent: 'reset', + returnTo: '/', + ...(serverUrl ? { serverUrl } : {}), + }); const kp = sodium.crypto_sign_seed_keypair(secretBytes); - const publicKey = encodeBase64(kp.publicKey, 'base64url'); + // Server expects standard base64 (not base64url) for `publicKey`. + const publicKey = encodeBase64(kp.publicKey); const url = await provider.getExternalSignupUrl({ publicKey }); if (!isSafeExternalAuthUrl(url)) { throw new Error('unsafe_url'); } - const supported = await Linking.canOpenURL(url); - if (!supported) { - throw new Error('unsupported_url'); + + if (Platform.OS === 'web') { + const location = (globalThis as any)?.window?.location; + if (location && typeof location.assign === 'function') { + location.assign(url); + return; + } + if (location && typeof location.href === 'string') { + location.href = url; + return; + } } + + const supported = await Linking.canOpenURL(url); + if (!supported) throw new Error('unsupported_url'); await Linking.openURL(url); - } catch { + } catch (error) { await TokenStorage.clearPendingExternalAuth(); - await Modal.alert(t('common.error'), t('errors.operationFailed')); + const message = process.env.EXPO_PUBLIC_DEBUG + ? formatOperationFailedDebugMessage(t('errors.operationFailed'), error) + : t('errors.operationFailed'); + await Modal.alert(t('common.error'), message); } }; if (providers === null) { return ( - <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> + <View style={styles.loading}> <ActivityIndicator size="small" /> </View> ); @@ -83,32 +117,109 @@ export default function LostAccess() { if (providers.length === 0) { return ( - <View style={{ flex: 1, padding: 24 }}> - <Text style={{ fontSize: 16 }}>{t('connect.lostAccessBody')}</Text> - <View style={{ height: 16 }} /> - <RoundButton title={t('common.back')} display="inverted" onPress={() => router.back()} /> - </View> + <ScrollView style={styles.scrollView} contentContainerStyle={{ flexGrow: 1 }}> + <View style={styles.container}> + <View style={styles.contentWrapper}> + <View style={styles.noticeCard}> + <Text style={styles.noticeBody}>{t('connect.lostAccessBody')}</Text> + </View> + <View style={styles.footer}> + <View style={styles.footerButton}> + <RoundButton size="normal" title={t('common.back')} display="inverted" onPress={() => router.back()} /> + </View> + </View> + </View> + </View> + </ScrollView> ); } return ( - <View style={{ flex: 1, padding: 24 }}> - <Text style={{ fontSize: 20, fontWeight: '600' }}>{t('connect.lostAccessTitle')}</Text> - <View style={{ height: 12 }} /> - <Text style={{ fontSize: 16 }}>{t('connect.lostAccessBody')}</Text> - <View style={{ height: 24 }} /> - {providers.map((providerId) => ( - <View key={providerId} style={{ marginBottom: 12 }}> - <RoundButton - title={t('connect.lostAccessContinue', { - provider: getAuthProvider(providerId)?.displayName ?? providerId, - })} - action={() => startReset(providerId)} - /> + <ScrollView style={styles.scrollView} contentContainerStyle={{ flexGrow: 1 }}> + <View style={styles.container}> + <View style={styles.contentWrapper}> + <View style={styles.noticeCard}> + <Text style={styles.noticeBody}>{t('connect.lostAccessBody')}</Text> + </View> + + <View style={styles.actions}> + {providers.map((providerId) => ( + <View key={providerId} style={styles.actionButton}> + <RoundButton + testID={`lost-access-provider-${providerId}`} + size="normal" + title={t('connect.lostAccessContinue', { + provider: getAuthProvider(providerId)?.displayName ?? providerId, + })} + action={() => startReset(providerId)} + /> + </View> + ))} + </View> + + <View style={styles.footer}> + <View style={styles.footerButton}> + <RoundButton size="normal" title={t('common.back')} display="inverted" onPress={() => router.back()} /> + </View> + </View> </View> - ))} - <View style={{ height: 12 }} /> - <RoundButton title={t('common.back')} display="inverted" onPress={() => router.back()} /> - </View> + </View> + </ScrollView> ); } + +const stylesheet = StyleSheet.create((theme) => ({ + scrollView: { + flex: 1, + backgroundColor: theme.colors.surface, + }, + loading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.surface, + }, + container: { + flex: 1, + alignItems: 'center', + paddingHorizontal: 24, + }, + contentWrapper: { + width: '100%', + maxWidth: Math.min(560, layout.maxWidth), + paddingVertical: 28, + }, + noticeCard: { + borderWidth: 1, + borderColor: theme.colors.divider, + borderRadius: 14, + paddingHorizontal: 16, + paddingVertical: 14, + backgroundColor: theme.colors.surface, + }, + noticeBody: { + fontSize: 15, + color: theme.colors.text, + lineHeight: 21, + ...Typography.default(), + }, + actions: { + marginTop: 18, + alignItems: 'center', + width: '100%', + }, + actionButton: { + width: '100%', + maxWidth: 360, + marginTop: 12, + }, + footer: { + marginTop: 18, + alignItems: 'center', + width: '100%', + }, + footerButton: { + width: '100%', + maxWidth: 360, + }, +})); diff --git a/apps/ui/sources/app/(app)/restore/manual.spec.tsx b/apps/ui/sources/app/(app)/restore/manual.spec.tsx index 9664b5ca1..8e27d45d2 100644 --- a/apps/ui/sources/app/(app)/restore/manual.spec.tsx +++ b/apps/ui/sources/app/(app)/restore/manual.spec.tsx @@ -7,6 +7,11 @@ type ReactActEnvironmentGlobal = typeof globalThis & { }; (globalThis as ReactActEnvironmentGlobal).IS_REACT_ACT_ENVIRONMENT = true; +const routerBackSpy = vi.hoisted(() => vi.fn()); +const routerReplaceSpy = vi.hoisted(() => vi.fn()); +const authLoginSpy = vi.hoisted(() => vi.fn(async () => {})); +const normalizeSecretKeySpy = vi.hoisted(() => vi.fn((input: string) => input.trim())); + vi.mock('react-native-reanimated', () => ({})); vi.mock('react-native', () => ({ @@ -15,21 +20,32 @@ vi.mock('react-native', () => ({ TextInput: 'TextInput', ScrollView: 'ScrollView', ActivityIndicator: 'ActivityIndicator', + AppState: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, Platform: { OS: 'web' }, })); vi.mock('expo-router', () => ({ - useRouter: () => ({ back: vi.fn(), push: vi.fn(), replace: vi.fn() }), + useRouter: () => ({ back: routerBackSpy, push: vi.fn(), replace: routerReplaceSpy }), })); vi.mock('@/auth/context/AuthContext', () => ({ - useAuth: () => ({ login: vi.fn(async () => {}) }), + useAuth: () => ({ login: authLoginSpy }), })); vi.mock('@/auth/flows/getToken', () => ({ authGetToken: vi.fn(async () => 'token'), })); +vi.mock('@/auth/recovery/secretKeyBackup', () => ({ + normalizeSecretKey: normalizeSecretKeySpy, +})); + +vi.mock('@/encryption/base64', () => ({ + decodeBase64: vi.fn((_value: string, _encoding: string) => new Uint8Array(32)), +})); + vi.mock('@/components/ui/buttons/RoundButton', () => ({ RoundButton: 'RoundButton', })); @@ -86,4 +102,36 @@ describe('/restore/manual', () => { }); } }); + + it('replaces navigation to home after a successful restore (does not return to link-new-device QR screen)', async () => { + vi.resetModules(); + const { default: Screen } = await import('./manual'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + if (!tree) throw new Error('Expected renderer'); + + const input = tree.root.find((node) => (node.props as any)?.testID === 'restore-manual-secret-input'); + await act(async () => { + input.props.onChangeText?.('secret-key'); + }); + + const submit = tree.root.find((node) => (node.props as any)?.testID === 'restore-manual-submit'); + await act(async () => { + await submit.props.action?.(); + }); + + expect(authLoginSpy).toHaveBeenCalled(); + expect(normalizeSecretKeySpy).toHaveBeenCalled(); + expect(routerBackSpy).not.toHaveBeenCalled(); + expect(routerReplaceSpy).toHaveBeenCalledWith('/'); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); }); diff --git a/apps/ui/sources/app/(app)/restore/manual.tsx b/apps/ui/sources/app/(app)/restore/manual.tsx index 86376ba69..b31d98ad2 100644 --- a/apps/ui/sources/app/(app)/restore/manual.tsx +++ b/apps/ui/sources/app/(app)/restore/manual.tsx @@ -1,19 +1,18 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { View, Text, TextInput, ScrollView, ActivityIndicator } from 'react-native'; +import React, { useState } from 'react'; +import { View, ScrollView } from 'react-native'; import { useRouter } from 'expo-router'; import { useAuth } from '@/auth/context/AuthContext'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; import { Typography } from '@/constants/Typography'; import { normalizeSecretKey } from '@/auth/recovery/secretKeyBackup'; import { authGetToken } from '@/auth/flows/getToken'; -import { decodeBase64, encodeBase64 } from '@/encryption/base64'; -import { generateAuthKeyPair, authQRStart, QRAuthKeyPair } from '@/auth/flows/qrStart'; -import { authQRWait } from '@/auth/flows/qrWait'; +import { decodeBase64 } from '@/encryption/base64'; import { layout } from '@/components/ui/layout/layout'; import { Modal } from '@/modal'; import { t } from '@/text'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { QRCode } from '@/components/qr/QRCode'; +import { Text, TextInput } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme) => ({ scrollView: { @@ -27,13 +26,22 @@ const stylesheet = StyleSheet.create((theme) => ({ }, contentWrapper: { width: '100%', - maxWidth: layout.maxWidth, - paddingVertical: 24, + maxWidth: Math.min(560, layout.maxWidth), + paddingVertical: 28, }, - instructionText: { - fontSize: 16, + noticeCard: { + borderWidth: 1, + borderColor: theme.colors.divider, + borderRadius: 14, + paddingHorizontal: 16, + paddingVertical: 14, + backgroundColor: theme.colors.surface, + marginBottom: 16, + }, + noticeText: { + fontSize: 15, color: theme.colors.textSecondary, - marginBottom: 20, + lineHeight: 21, ...Typography.default(), }, secondInstructionText: { @@ -98,11 +106,10 @@ export default function Restore() { // Login with new credentials await auth.login(token, normalizedKey); - // Dismiss - router.back(); + // Navigate home after restore to avoid returning to the link-new-device QR screen. + router.replace('/'); } catch (error) { - console.error('Restore error:', error); Modal.alert(t('common.error'), t('connect.invalidSecretKey')); } }; @@ -111,11 +118,12 @@ export default function Restore() { <ScrollView style={styles.scrollView}> <View style={styles.container}> <View style={styles.contentWrapper}> - <Text style={styles.instructionText}> - {t('connect.restoreWithSecretKeyDescription')} - </Text> + <View style={styles.noticeCard}> + <Text style={styles.noticeText}>{t('connect.restoreWithSecretKeyDescription')}</Text> + </View> <TextInput + testID="restore-manual-secret-input" style={styles.textInput} placeholder={t('connect.secretKeyPlaceholder')} placeholderTextColor={theme.colors.input.placeholder} @@ -130,6 +138,7 @@ export default function Restore() { /> <RoundButton + testID="restore-manual-submit" title={t('connect.restoreAccount')} action={handleRestore} /> diff --git a/apps/ui/sources/app/(app)/rootLayout.notifications.spec.tsx b/apps/ui/sources/app/(app)/rootLayout.notifications.spec.tsx index 8b713d532..97c85adf4 100644 --- a/apps/ui/sources/app/(app)/rootLayout.notifications.spec.tsx +++ b/apps/ui/sources/app/(app)/rootLayout.notifications.spec.tsx @@ -90,10 +90,11 @@ vi.mock('@/sync/domains/state/storage', () => ({ useProfile: () => ({ linkedProviders: [], username: 'u' }), })); -vi.mock('@/sync/domains/state/storageStore', () => ({ - storage: (selector: (state: { profile: { linkedProviders: []; username: string } }) => unknown) => - selector({ profile: { linkedProviders: [], username: 'u' } }), -})); +vi.mock('@/sync/domains/state/storageStore', () => { + const storage = (selector: (state: { profile: { linkedProviders: []; username: string } }) => unknown) => + selector({ profile: { linkedProviders: [], username: 'u' } }); + return { storage, getStorage: () => storage }; +}); vi.mock('@/sync/sync', () => ({ sync: { diff --git a/apps/ui/sources/app/(app)/rootLayout.serverOverride.spec.tsx b/apps/ui/sources/app/(app)/rootLayout.serverOverride.spec.tsx index 717c85f27..5e74bab8e 100644 --- a/apps/ui/sources/app/(app)/rootLayout.serverOverride.spec.tsx +++ b/apps/ui/sources/app/(app)/rootLayout.serverOverride.spec.tsx @@ -97,6 +97,7 @@ vi.mock('@/sync/domains/pending/pendingNotificationNav', () => ({ vi.mock('@/sync/domains/server/serverProfiles', () => ({ getActiveServerUrl: () => activeServerUrl, + getActiveServerSnapshot: () => ({ serverId: 'server-a', serverUrl: activeServerUrl, generation: 1 }), })); vi.mock('@/sync/domains/server/activeServerSwitch', () => ({ diff --git a/apps/ui/sources/app/(app)/rootLayout.voiceGate.spec.tsx b/apps/ui/sources/app/(app)/rootLayout.voiceGate.spec.tsx index 40fed5789..cd0b7aca2 100644 --- a/apps/ui/sources/app/(app)/rootLayout.voiceGate.spec.tsx +++ b/apps/ui/sources/app/(app)/rootLayout.voiceGate.spec.tsx @@ -74,9 +74,10 @@ vi.mock('@/sync/domains/state/storage', () => ({ useProfile: () => ({ linkedProviders: [], username: null }), })); -vi.mock('@/sync/domains/state/storageStore', () => ({ - storage: (selector: (state: { profile: { linkedProviders: []; username: null } }) => unknown) => selector({ profile: { linkedProviders: [], username: null } }), -})); +vi.mock('@/sync/domains/state/storageStore', () => { + const storage = (selector: (state: { profile: { linkedProviders: []; username: null } }) => unknown) => selector({ profile: { linkedProviders: [], username: null } }); + return { storage, getStorage: () => storage }; +}); vi.mock('@/sync/sync', () => ({ sync: { applySettings: (delta: Record<string, unknown>) => applySettings(delta) }, diff --git a/apps/ui/sources/app/(app)/runs.test.tsx b/apps/ui/sources/app/(app)/runs.test.tsx index 33e115984..5d4c0937f 100644 --- a/apps/ui/sources/app/(app)/runs.test.tsx +++ b/apps/ui/sources/app/(app)/runs.test.tsx @@ -12,15 +12,13 @@ const machineExecutionRunsListSpy = vi.fn(async (..._args: MachineExecutionRunsL })); const stackScreenSpy = vi.fn((_props: any) => null); -vi.mock('react-native', () => ({ - Platform: { OS: 'web', select: (values: any) => values?.web ?? values?.default }, - View: 'View', - Text: 'Text', - ScrollView: 'ScrollView', - Pressable: 'Pressable', - ActivityIndicator: 'ActivityIndicator', - RefreshControl: 'RefreshControl', -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + Platform: { ...rn.Platform, OS: 'web', select: (values: any) => values?.web ?? values?.default }, + }; +}); const routerMock = { push: vi.fn(), back: vi.fn(), replace: vi.fn(), navigate: vi.fn() }; vi.mock('expo-router', () => ({ @@ -28,22 +26,24 @@ vi.mock('expo-router', () => ({ Stack: { Screen: (props: any) => stackScreenSpy(props) }, })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - surface: '#111', - surfaceHigh: '#222', - divider: '#333', - text: '#eee', - textSecondary: '#aaa', - header: { tint: '#eee' }, - status: { error: '#f00' }, - }, +vi.mock('react-native-unistyles', () => { + const theme = { + colors: { + surface: '#111', + surfaceHigh: '#222', + divider: '#333', + shadow: { color: '#000', opacity: 0.2 }, + text: '#eee', + textSecondary: '#aaa', + header: { tint: '#eee' }, + status: { error: '#f00' }, }, - }), - StyleSheet: { create: (fn: any) => (typeof fn === 'function' ? fn({ colors: {} }) : fn) }, -})); + }; + return { + useUnistyles: () => ({ theme }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input(theme) : input) }, + }; +}); vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons' })); diff --git a/apps/ui/sources/app/(app)/runs.tsx b/apps/ui/sources/app/(app)/runs.tsx index 0b0af648d..f3668551d 100644 --- a/apps/ui/sources/app/(app)/runs.tsx +++ b/apps/ui/sources/app/(app)/runs.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, View } from 'react-native'; import { Stack, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; @@ -13,11 +13,15 @@ import { ExecutionRunRow } from '@/components/sessions/runs/ExecutionRunRow'; import { ConstrainedScreenContent } from '@/components/ui/layout/ConstrainedScreenContent'; import { Modal } from '@/modal'; import { t } from '@/text'; +import { tryShowDaemonUnavailableAlertForRpcFailure } from '@/utils/errors/daemonUnavailableAlert'; import { useMachineListByServerId, useMachineListStatusByServerId } from '@/sync/domains/state/storage'; import { machineExecutionRunsList } from '@/sync/ops/machineExecutionRuns'; import { sessionExecutionRunStop } from '@/sync/ops/sessionExecutionRuns'; import { machineStopSession } from '@/sync/ops/machines'; import { isMachineOnline } from '@/utils/sessions/machineUtils'; +import { Text } from '@/components/ui/text/Text'; +import { useMountedShouldContinue } from '@/hooks/ui/useMountedShouldContinue'; + type MachineRunsState = | { status: 'idle' } @@ -49,6 +53,7 @@ function formatRunDetails(run: DaemonExecutionRunEntry): string { export default function RunsScreen() { const { theme } = useUnistyles(); const router = useRouter(); + const shouldContinue = useMountedShouldContinue(); const machineListByServerId = useMachineListByServerId(); const machineListStatusByServerId = useMachineListStatusByServerId(); const [showFinished, setShowFinished] = React.useState(false); @@ -192,6 +197,23 @@ export default function RunsScreen() { const onStop = async () => { if (!canStop) return; setStoppingRunId(run.runId); + const stopSessionProcess = async () => { + const stopResult = await machineStopSession(machineId, run.happySessionId, { serverId }); + if (stopResult.ok) return; + + const shownDaemonUnavailable = tryShowDaemonUnavailableAlertForRpcFailure({ + rpcErrorCode: stopResult.errorCode ?? null, + message: stopResult.error ?? null, + machine, + onRetry: () => { + void stopSessionProcess(); + }, + shouldContinue, + }); + if (!shownDaemonUnavailable) { + Modal.alert(t('common.error'), stopResult.error || 'Failed to stop session'); + } + }; try { const res = await sessionExecutionRunStop(run.happySessionId, { runId: run.runId }, { serverId }); if ((res as any)?.ok === false) { @@ -201,10 +223,7 @@ export default function RunsScreen() { { confirmText: 'Stop session', cancelText: 'Cancel', destructive: true }, ); if (confirmed) { - const stopResult = await machineStopSession(machineId, run.happySessionId, { serverId }); - if (!stopResult.ok) { - Modal.alert(t('common.error'), stopResult.error || 'Failed to stop session'); - } + await stopSessionProcess(); } else { Modal.alert(t('common.error'), String((res as any).error ?? 'Failed to stop run')); } @@ -216,10 +235,7 @@ export default function RunsScreen() { { confirmText: 'Stop session', cancelText: 'Cancel', destructive: true }, ); if (confirmed) { - const stopResult = await machineStopSession(machineId, run.happySessionId, { serverId }); - if (!stopResult.ok) { - Modal.alert(t('common.error'), stopResult.error || 'Failed to stop session'); - } + await stopSessionProcess(); } else { Modal.alert(t('common.error'), error instanceof Error ? error.message : 'Failed to stop run'); } @@ -246,7 +262,7 @@ export default function RunsScreen() { {stoppingRunId === run.runId ? ( <ActivityIndicator size="small" color={theme.colors.textSecondary} /> ) : ( - <Ionicons name="stop-circle-outline" size={20} color="#FF9500" /> + <Ionicons name="stop-circle-outline" size={20} color={theme.colors.accent.orange} /> )} </Pressable> ) : null} diff --git a/apps/ui/sources/app/(app)/search.memoryRpc.test.tsx b/apps/ui/sources/app/(app)/search.memoryRpc.test.tsx index d9efc40d6..98ba85f43 100644 --- a/apps/ui/sources/app/(app)/search.memoryRpc.test.tsx +++ b/apps/ui/sources/app/(app)/search.memoryRpc.test.tsx @@ -17,17 +17,28 @@ vi.mock('react-native', () => ({ OS: 'web', select: (options: any) => (options && 'default' in options ? options.default : undefined), }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); vi.mock('expo-router', () => ({ useRouter: () => ({ push: routerPushSpy }), })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { colors: { text: '#111', textSecondary: '#666', input: { placeholder: '#999', background: '#fff' } } }, - }), -})); +vi.mock('react-native-unistyles', () => { + const theme = { + colors: { + text: '#111', + textSecondary: '#666', + shadow: { color: '#000', opacity: 0.2 }, + input: { placeholder: '#999', background: '#fff' }, + }, + }; + + return { + StyleSheet: { create: (styles: any) => (typeof styles === 'function' ? styles(theme) : styles) }, + useUnistyles: () => ({ theme }), + }; +}); vi.mock('@/text', () => ({ t: (key: string) => key, diff --git a/apps/ui/sources/app/(app)/session/[id]/commit.test.tsx b/apps/ui/sources/app/(app)/session/[id]/commit.test.tsx index 9a86ff4bf..2c09d1565 100644 --- a/apps/ui/sources/app/(app)/session/[id]/commit.test.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/commit.test.tsx @@ -61,8 +61,9 @@ vi.mock('@/constants/Typography', () => ({ }, })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/components/ui/code/view/CodeLinesView', () => ({ diff --git a/apps/ui/sources/app/(app)/session/[id]/commit.tsx b/apps/ui/sources/app/(app)/session/[id]/commit.tsx index a6e714b35..68b65d484 100644 --- a/apps/ui/sources/app/(app)/session/[id]/commit.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/commit.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { View, ActivityIndicator, Platform, Pressable } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { CodeLinesView } from '@/components/ui/code/view/CodeLinesView'; import { buildCodeLinesFromUnifiedDiff } from '@/components/ui/code/model/buildCodeLinesFromUnifiedDiff'; import { Typography } from '@/constants/Typography'; diff --git a/apps/ui/sources/app/(app)/session/[id]/files.test.tsx b/apps/ui/sources/app/(app)/session/[id]/files.test.tsx index 0eead62f1..6fdaaa9aa 100644 --- a/apps/ui/sources/app/(app)/session/[id]/files.test.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/files.test.tsx @@ -101,8 +101,9 @@ vi.mock('@expo/vector-icons', () => ({ Octicons: 'Octicons', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/components/ui/lists/ItemList', () => ({ diff --git a/apps/ui/sources/app/(app)/session/[id]/info.tsx b/apps/ui/sources/app/(app)/session/[id]/info.tsx index 6b266f333..4f5f2b956 100644 --- a/apps/ui/sources/app/(app)/session/[id]/info.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/info.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { View, Text, Animated } from 'react-native'; +import { View, Animated } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; @@ -27,6 +27,8 @@ import { DEFAULT_AGENT_ID, getAgentCore, resolveAgentIdFromFlavor } from '@/agen import { useSessionSharingSupport } from '@/hooks/session/useSessionSharingSupport'; import { useAutomationsSupport } from '@/hooks/server/useAutomationsSupport'; import { useFeatureEnabled } from '@/hooks/server/useFeatureEnabled'; +import { Text } from '@/components/ui/text/Text'; + // Animated status dot component function StatusDot({ color, isPulsing, size = 8 }: { color: string; isPulsing?: boolean; size?: number }) { @@ -317,7 +319,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.cliVersionOutdated')} subtitle={t('sessionInfo.updateCliInstructions')} - icon={<Ionicons name="warning-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="warning-outline" size={29} color={theme.colors.accent.orange} />} showChevron={false} onPress={handleCopyUpdateCommand} /> @@ -329,14 +331,14 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.happySessionId')} subtitle={`${session.id.substring(0, 8)}...${session.id.substring(session.id.length - 8)}`} - icon={<Ionicons name="finger-print-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="finger-print-outline" size={29} color={theme.colors.accent.blue} />} onPress={handleCopySessionId} /> {vendorResumeId && vendorResumeLabelKey && vendorResumeCopiedKey && ( <Item title={t(vendorResumeLabelKey)} subtitle={`${vendorResumeId.substring(0, 8)}...${vendorResumeId.substring(vendorResumeId.length - 8)}`} - icon={<Ionicons name={core.ui.agentPickerIconName as any} size={29} color="#007AFF" />} + icon={<Ionicons name={core.ui.agentPickerIconName as any} size={29} color={theme.colors.accent.blue} />} onPress={async () => { try { await Clipboard.setStringAsync(vendorResumeId); @@ -356,19 +358,19 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.created')} subtitle={formatDate(session.createdAt)} - icon={<Ionicons name="calendar-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="calendar-outline" size={29} color={theme.colors.accent.blue} />} showChevron={false} /> <Item title={t('sessionInfo.lastUpdated')} subtitle={formatDate(session.updatedAt)} - icon={<Ionicons name="time-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="time-outline" size={29} color={theme.colors.accent.blue} />} showChevron={false} /> <Item title={t('sessionInfo.sequence')} detail={session.seq.toString()} - icon={<Ionicons name="git-commit-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="git-commit-outline" size={29} color={theme.colors.accent.blue} />} showChevron={false} /> </ItemGroup> @@ -378,14 +380,14 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.renameSession')} subtitle={t('sessionInfo.renameSessionSubtitle')} - icon={<Ionicons name="pencil-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="pencil-outline" size={29} color={theme.colors.accent.blue} />} onPress={handleRenameSession} /> {executionRunsEnabled ? ( <Item title={t('runs.title') ?? 'Runs'} subtitle={'See execution runs for this session'} - icon={<Ionicons name="play-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="play-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push(`/session/${session.id}/runs`)} /> ) : null} @@ -393,7 +395,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title="Automations" subtitle="Manage scheduled messages for this session" - icon={<Ionicons name="timer-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="timer-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push(`/session/${session.id}/automations`)} /> ) : null} @@ -401,7 +403,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.copyResumeCommand')} subtitle={`happier resume ${session.id}`} - icon={<Ionicons name="terminal-outline" size={29} color="#9C27B0" />} + icon={<Ionicons name="terminal-outline" size={29} color={theme.colors.accent.purple} />} showChevron={false} onPress={() => handleCopyCommand(`happier resume ${session.id}`)} /> @@ -410,7 +412,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title="View session log" subtitle="Open live log tail for this session" - icon={<Ionicons name="document-text-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="document-text-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push(`/session/${session.id}/log`)} /> )} @@ -418,7 +420,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.viewMachine')} subtitle={t('sessionInfo.viewMachineSubtitle')} - icon={<Ionicons name="server-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="server-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push(`/machine/${session.metadata?.machineId}`)} /> )} @@ -426,7 +428,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.manageSharing')} subtitle={t('sessionInfo.manageSharingSubtitle')} - icon={<Ionicons name="share-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="share-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push(`/session/${session.id}/sharing`)} /> )} @@ -434,7 +436,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.stopSession')} subtitle={t('sessionInfo.stopSessionSubtitle')} - icon={<Ionicons name="stop-circle-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="stop-circle-outline" size={29} color={theme.colors.warningCritical} />} onPress={handleStopSession} /> )} @@ -442,7 +444,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.archiveSession')} subtitle={t('sessionInfo.archiveSessionSubtitle')} - icon={<Ionicons name="archive-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="archive-outline" size={29} color={theme.colors.warningCritical} />} onPress={handleArchiveSession} /> )} @@ -450,7 +452,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.deleteSession')} subtitle={t('sessionInfo.deleteSessionSubtitle')} - icon={<Ionicons name="trash-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="trash-outline" size={29} color={theme.colors.warningCritical} />} onPress={handleDeleteSession} /> )} @@ -462,13 +464,13 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.host')} subtitle={session.metadata.host} - icon={<Ionicons name="desktop-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="desktop-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} /> <Item title={t('sessionInfo.path')} subtitle={formatPathRelativeToHome(session.metadata.path, session.metadata.homeDir)} - icon={<Ionicons name="folder-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="folder-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} /> {session.metadata.version && ( @@ -476,7 +478,7 @@ function SessionInfoContent({ session }: { session: Session }) { title={t('sessionInfo.cliVersion')} subtitle={session.metadata.version} detail={isCliOutdated ? '⚠️' : undefined} - icon={<Ionicons name="git-branch-outline" size={29} color={isCliOutdated ? "#FF9500" : "#5856D6"} />} + icon={<Ionicons name="git-branch-outline" size={29} color={isCliOutdated ? theme.colors.accent.orange : theme.colors.accent.indigo} />} showChevron={false} /> )} @@ -484,7 +486,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.operatingSystem')} subtitle={formatOSPlatform(session.metadata.os)} - icon={<Ionicons name="hardware-chip-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="hardware-chip-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} /> )} @@ -498,14 +500,14 @@ function SessionInfoContent({ session }: { session: Session }) { ? flavor : t(getAgentCore(DEFAULT_AGENT_ID).displayNameKey); })()} - icon={<Ionicons name="sparkles-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="sparkles-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} /> {useProfiles && session.metadata?.profileId !== undefined && ( <Item title={t('sessionInfo.aiProfile')} detail={profileLabel} - icon={<Ionicons name="person-circle-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="person-circle-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} /> )} @@ -513,7 +515,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.processId')} subtitle={session.metadata.hostPid.toString()} - icon={<Ionicons name="terminal-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="terminal-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} /> )} @@ -521,7 +523,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.happyHome')} subtitle={formatPathRelativeToHome(session.metadata.happyHomeDir, session.metadata.homeDir)} - icon={<Ionicons name="home-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="home-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} /> )} @@ -529,7 +531,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title="Session log path" subtitle={formatPathRelativeToHome(sessionLogPath, session.metadata.homeDir)} - icon={<Ionicons name="document-text-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="document-text-outline" size={29} color={theme.colors.accent.indigo} />} onPress={handleCopySessionLogPath} showChevron={false} /> @@ -538,7 +540,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.attachFromTerminal')} subtitle={attachCommand} - icon={<Ionicons name="terminal-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="terminal-outline" size={29} color={theme.colors.accent.indigo} />} onPress={handleCopyAttachCommand} showChevron={false} /> @@ -547,7 +549,7 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.tmuxTarget')} subtitle={tmuxTarget} - icon={<Ionicons name="albums-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="albums-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} /> )} @@ -555,13 +557,13 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.tmuxFallback')} subtitle={tmuxFallbackReason} - icon={<Ionicons name="alert-circle-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="alert-circle-outline" size={29} color={theme.colors.accent.orange} />} showChevron={false} /> )} <Item title={t('sessionInfo.copyMetadata')} - icon={<Ionicons name="copy-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="copy-outline" size={29} color={theme.colors.accent.blue} />} onPress={handleCopyMetadata} /> </ItemGroup> @@ -573,14 +575,14 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.controlledByUser')} detail={session.agentState.controlledByUser ? t('common.yes') : t('common.no')} - icon={<Ionicons name="person-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="person-outline" size={29} color={theme.colors.accent.orange} />} showChevron={false} /> {session.agentState.requests && Object.keys(session.agentState.requests).length > 0 && ( <Item title={t('sessionInfo.pendingRequests')} detail={Object.keys(session.agentState.requests).length.toString()} - icon={<Ionicons name="hourglass-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="hourglass-outline" size={29} color={theme.colors.accent.orange} />} showChevron={false} /> )} @@ -592,14 +594,14 @@ function SessionInfoContent({ session }: { session: Session }) { <Item title={t('sessionInfo.thinking')} detail={session.thinking ? t('common.yes') : t('common.no')} - icon={<Ionicons name="bulb-outline" size={29} color={session.thinking ? "#FFCC00" : "#8E8E93"} />} + icon={<Ionicons name="bulb-outline" size={29} color={session.thinking ? theme.colors.accent.yellow : theme.colors.textSecondary} />} showChevron={false} /> {session.thinking && ( <Item title={t('sessionInfo.thinkingSince')} subtitle={formatDate(session.thinkingAt)} - icon={<Ionicons name="timer-outline" size={29} color="#FFCC00" />} + icon={<Ionicons name="timer-outline" size={29} color={theme.colors.accent.yellow} />} showChevron={false} /> )} @@ -612,7 +614,7 @@ function SessionInfoContent({ session }: { session: Session }) { <> <Item title={t('sessionInfo.agentState')} - icon={<Ionicons name="code-working-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="code-working-outline" size={29} color={theme.colors.accent.orange} />} showChevron={false} /> <View style={{ marginHorizontal: 16, marginBottom: 12 }}> @@ -627,7 +629,7 @@ function SessionInfoContent({ session }: { session: Session }) { <> <Item title={t('sessionInfo.metadata')} - icon={<Ionicons name="information-circle-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="information-circle-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} /> <View style={{ marginHorizontal: 16, marginBottom: 12 }}> @@ -642,7 +644,7 @@ function SessionInfoContent({ session }: { session: Session }) { <> <Item title={t('sessionInfo.sessionStatus')} - icon={<Ionicons name="analytics-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="analytics-outline" size={29} color={theme.colors.accent.blue} />} showChevron={false} /> <View style={{ marginHorizontal: 16, marginBottom: 12 }}> @@ -662,7 +664,7 @@ function SessionInfoContent({ session }: { session: Session }) { {/* Full Session Object */} <Item title={t('sessionInfo.fullSessionObject')} - icon={<Ionicons name="document-text-outline" size={29} color="#34C759" />} + icon={<Ionicons name="document-text-outline" size={29} color={theme.colors.success} />} showChevron={false} /> <View style={{ marginHorizontal: 16, marginBottom: 12 }}> diff --git a/apps/ui/sources/app/(app)/session/[id]/log.test.tsx b/apps/ui/sources/app/(app)/session/[id]/log.test.tsx index 963ef143b..3789b627e 100644 --- a/apps/ui/sources/app/(app)/session/[id]/log.test.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/log.test.tsx @@ -17,34 +17,18 @@ vi.mock('expo-router', () => ({ useLocalSearchParams: () => ({ id: 'session-1' }), })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - ScrollView: 'ScrollView', - Pressable: 'Pressable', - Platform: { - OS: 'ios', - select: (spec: Record<string, unknown>) => - spec && Object.prototype.hasOwnProperty.call(spec, 'ios') ? (spec as any).ios : (spec as any).default, - }, -})); - -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - text: '#000', - textSecondary: '#666', - surface: '#fff', - border: '#ddd', - }, +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + Platform: { + ...rn.Platform, + OS: 'ios', + select: (spec: Record<string, unknown>) => + spec && Object.prototype.hasOwnProperty.call(spec, 'ios') ? (spec as any).ios : (spec as any).default, }, - }), - StyleSheet: { - create: (styles: any) => styles, - absoluteFillObject: {}, - }, -})); + }; +}); vi.mock('@expo/vector-icons', async () => { const Ionicons = (props: any) => React.createElement('Ionicons', props); diff --git a/apps/ui/sources/app/(app)/session/[id]/log.tsx b/apps/ui/sources/app/(app)/session/[id]/log.tsx index 4dfd0310f..8aecb6348 100644 --- a/apps/ui/sources/app/(app)/session/[id]/log.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/log.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { useLocalSearchParams } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; @@ -14,6 +14,8 @@ import { useIsDataReady, useLocalSetting, useSession } from '@/sync/domains/stat import { sessionReadLogTail } from '@/sync/ops'; import { t } from '@/text'; import { useUnistyles } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + const LOG_TAIL_MAX_BYTES = 200_000; @@ -119,7 +121,7 @@ export default function SessionLogScreen() { <Item title="Log path" subtitle={resolvedLogPath || metadataLogPath || 'Unavailable'} - icon={<Ionicons name="document-text-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="document-text-outline" size={29} color={theme.colors.accent.indigo} />} showChevron={false} onPress={() => { const path = resolvedLogPath || metadataLogPath; @@ -130,14 +132,14 @@ export default function SessionLogScreen() { <Item title="Refresh log tail" subtitle={loading ? 'Loading…' : `Read last ${LOG_TAIL_MAX_BYTES.toLocaleString()} bytes`} - icon={<Ionicons name="refresh-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="refresh-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => void refreshTail()} showChevron={false} /> <Item title="Copy visible log" subtitle={tailText.length > 0 ? 'Copy current tail to clipboard' : 'No log content loaded'} - icon={<Ionicons name="copy-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="copy-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => void copyText('Session log', tailText)} showChevron={false} disabled={tailText.length === 0} @@ -149,7 +151,7 @@ export default function SessionLogScreen() { <Item title="Read error" subtitle={error} - icon={<Ionicons name="alert-circle-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="alert-circle-outline" size={29} color={theme.colors.warningCritical} />} showChevron={false} /> </ItemGroup> diff --git a/apps/ui/sources/app/(app)/session/[id]/message/[messageId].tsx b/apps/ui/sources/app/(app)/session/[id]/message/[messageId].tsx index 242752bf9..2434ccd07 100644 --- a/apps/ui/sources/app/(app)/session/[id]/message/[messageId].tsx +++ b/apps/ui/sources/app/(app)/session/[id]/message/[messageId].tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { useLocalSearchParams, Stack, useRouter } from "expo-router"; -import { Text, View, ActivityIndicator } from "react-native"; +import { View, ActivityIndicator } from 'react-native'; import { useMessage, useSession, useSessionMessages } from "@/sync/domains/state/storage"; import { sync } from '@/sync/sync'; import { Deferred } from "@/components/ui/forms/Deferred"; @@ -10,6 +10,8 @@ import { ToolStatusIndicator } from '@/components/tools/shell/presentation/ToolS import { Message } from '@/sync/domains/messages/messageTypes'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme) => ({ loadingContainer: { diff --git a/apps/ui/sources/app/(app)/session/[id]/runs.test.tsx b/apps/ui/sources/app/(app)/session/[id]/runs.test.tsx index 48bfb6447..a9a61c1c1 100644 --- a/apps/ui/sources/app/(app)/session/[id]/runs.test.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/runs.test.tsx @@ -23,27 +23,8 @@ const listRunsSpy = vi.fn(async (_sessionId: string, _params: Record<string, unk const routerPushSpy = vi.fn(); const stackScreenSpy = vi.fn((_props: any) => null); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - Pressable: 'Pressable', - ActivityIndicator: 'ActivityIndicator', -})); +vi.mock('react-native', async () => await import('@/dev/reactNativeStub')); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - surface: '#111', - surfaceHigh: '#222', - divider: '#333', - text: '#eee', - textSecondary: '#aaa', - }, - }, - }), - StyleSheet: { create: (fn: any) => fn({ colors: { surfaceHigh: '#222', divider: '#333', text: '#eee', textSecondary: '#aaa' } }) }, -})); vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons' })); vi.mock('expo-router', () => ({ diff --git a/apps/ui/sources/app/(app)/session/[id]/runs.tsx b/apps/ui/sources/app/(app)/session/[id]/runs.tsx index bfa27d6f8..a361a73b4 100644 --- a/apps/ui/sources/app/(app)/session/[id]/runs.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/runs.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, View } from 'react-native'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; @@ -9,6 +9,8 @@ import { sessionExecutionRunList } from '@/sync/ops/sessionExecutionRuns'; import { t } from '@/text'; import { ExecutionRunList } from '@/components/sessions/runs/ExecutionRunList'; import { ConstrainedScreenContent } from '@/components/ui/layout/ConstrainedScreenContent'; +import { Text } from '@/components/ui/text/Text'; + type LoadState = | { status: 'loading' } diff --git a/apps/ui/sources/app/(app)/session/[id]/runs/[runId].tsx b/apps/ui/sources/app/(app)/session/[id]/runs/[runId].tsx index efcd63ca0..5ea46a630 100644 --- a/apps/ui/sources/app/(app)/session/[id]/runs/[runId].tsx +++ b/apps/ui/sources/app/(app)/session/[id]/runs/[runId].tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'; +import { ActivityIndicator, Pressable, View } from 'react-native'; import { Stack, useLocalSearchParams } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; @@ -14,6 +14,8 @@ import { renderExecutionRunStructuredMeta } from '@/components/sessions/runs/ren import { ExecutionRunDetailsPanel } from '@/components/sessions/runs/ExecutionRunDetailsPanel'; import { ConstrainedScreenContent } from '@/components/ui/layout/ConstrainedScreenContent'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { Text, TextInput } from '@/components/ui/text/Text'; + type LoadState = | { status: 'loading' } diff --git a/apps/ui/sources/app/(app)/session/[id]/runs/new.tsx b/apps/ui/sources/app/(app)/session/[id]/runs/new.tsx index f59b6b217..531b0c42e 100644 --- a/apps/ui/sources/app/(app)/session/[id]/runs/new.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/runs/new.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useUnistyles } from 'react-native-unistyles'; @@ -11,6 +11,8 @@ import { t } from '@/text'; import { buildExecutionRunsGuidanceBlock, coerceExecutionRunsGuidanceEntries } from '@/sync/domains/settings/executionRunsGuidance'; import { buildAvailableReviewEngineOptions } from '@/sync/domains/reviews/reviewEngineCatalog'; import { ConstrainedScreenContent } from '@/components/ui/layout/ConstrainedScreenContent'; +import { Text, TextInput } from '@/components/ui/text/Text'; + type ExecutionRunIntent = 'review' | 'plan' | 'delegate' | 'voice_agent'; diff --git a/apps/ui/sources/app/(app)/session/[id]/sharing.tsx b/apps/ui/sources/app/(app)/session/[id]/sharing.tsx index 4a90d27f3..e4700c51c 100644 --- a/apps/ui/sources/app/(app)/session/[id]/sharing.tsx +++ b/apps/ui/sources/app/(app)/session/[id]/sharing.tsx @@ -1,5 +1,5 @@ import React, { memo, useState, useCallback, useEffect, useRef } from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/ui/lists/Item'; @@ -28,6 +28,9 @@ import { UserProfile } from '@/sync/domains/social/friendTypes'; import { encryptDataKeyForPublicShare } from '@/sync/encryption/publicShareEncryption'; import { getRandomBytes } from 'expo-crypto'; import { encryptDataKeyForRecipientV0, verifyRecipientContentPublicKeyBinding } from '@/sync/encryption/directShareEncryption'; +import { buildCreateSessionShareRequest } from '@/sync/domains/social/sharingRequests/buildCreateSessionShareRequest'; +import { Text } from '@/components/ui/text/Text'; + function SharingManagementContent({ sessionId }: { sessionId: string }) { const { theme } = useUnistyles(); @@ -95,38 +98,50 @@ function SharingManagementContent({ sessionId }: { sessionId: string }) { if (!friend) { throw new HappyError(t('errors.operationFailed'), false); } - if (!friend.contentPublicKey || !friend.contentPublicKeySig) { - throw new HappyError(t('session.sharing.recipientMissingKeys'), false); - } - const isValidBinding = verifyRecipientContentPublicKeyBinding({ - signingPublicKeyHex: friend.publicKey, - contentPublicKeyB64: friend.contentPublicKey, - contentPublicKeySigB64: friend.contentPublicKeySig, - }); - if (!isValidBinding) { - throw new HappyError(t('errors.operationFailed'), false); - } - - // Get plaintext session DEK from the sync layer (owner/admin only) - const dataKey = sync.getSessionDataKey(sessionId); - if (!dataKey) { - throw new HappyError(t('errors.sessionNotFound'), false); - } - const encryptedDataKey = encryptDataKeyForRecipientV0(dataKey, friend.contentPublicKey); - - await createSessionShare(credentials, sessionId, { - userId, - accessLevel, - ...(canApprovePermissions !== undefined ? { canApprovePermissions } : {}), - encryptedDataKey, - }); + const sessionEncryptionMode = session?.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + + const encryptedDataKey = + sessionEncryptionMode === 'plain' + ? undefined + : (() => { + if (!friend.contentPublicKey || !friend.contentPublicKeySig) { + throw new HappyError(t('session.sharing.recipientMissingKeys'), false); + } + const isValidBinding = verifyRecipientContentPublicKeyBinding({ + signingPublicKeyHex: friend.publicKey, + contentPublicKeyB64: friend.contentPublicKey, + contentPublicKeySigB64: friend.contentPublicKeySig, + }); + if (!isValidBinding) { + throw new HappyError(t('errors.operationFailed'), false); + } + + // Get plaintext session DEK from the sync layer (owner/admin only) + const dataKey = sync.getSessionDataKey(sessionId); + if (!dataKey) { + throw new HappyError(t('errors.sessionNotFound'), false); + } + return encryptDataKeyForRecipientV0(dataKey, friend.contentPublicKey); + })(); + + await createSessionShare( + credentials, + sessionId, + buildCreateSessionShareRequest({ + sessionEncryptionMode, + userId, + accessLevel, + ...(canApprovePermissions !== undefined ? { canApprovePermissions } : {}), + ...(encryptedDataKey ? { encryptedDataKey } : {}), + }), + ); await loadSharingData(); setShowFriendSelector(false); } catch (error) { throw new HappyError(t('errors.operationFailed'), false); } - }, [friends, sessionId, loadSharingData]); + }, [friends, sessionId, loadSharingData, session?.encryptionMode]); // Handle updating share access level const handleUpdateShare = useCallback(async (shareId: string, patch: { accessLevel?: ShareAccessLevel; canApprovePermissions?: boolean }) => { @@ -159,28 +174,31 @@ function SharingManagementContent({ sessionId }: { sessionId: string }) { try { const credentials = sync.getCredentials(); + const sessionEncryptionMode = session?.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + // Generate random token (12 bytes = 24 hex chars) const tokenBytes = getRandomBytes(12); const token = Array.from(tokenBytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); - // Get session data encryption key - const dataKey = sync.getSessionDataKey(sessionId); - if (!dataKey) { - throw new HappyError(t('errors.sessionNotFound'), false); + let encryptedDataKey: string | undefined; + if (sessionEncryptionMode === 'e2ee') { + // Get plaintext session DEK from the sync layer (owner/admin only) + const dataKey = sync.getSessionDataKey(sessionId); + if (!dataKey) { + throw new HappyError(t('errors.sessionNotFound'), false); + } + encryptedDataKey = await encryptDataKeyForPublicShare(dataKey, token); } - // Encrypt data key with the token - const encryptedDataKey = await encryptDataKeyForPublicShare(dataKey, token); - const expiresAt = options.expiresInDays ? Date.now() + options.expiresInDays * 24 * 60 * 60 * 1000 : undefined; const created = await createPublicShare(credentials, sessionId, { token, - encryptedDataKey, + ...(encryptedDataKey ? { encryptedDataKey } : {}), expiresAt, maxUses: options.maxUses, isConsentRequired: options.isConsentRequired, @@ -265,21 +283,21 @@ function SharingManagementContent({ sessionId }: { sessionId: string }) { key={share.id} title={share.sharedWithUser.username || [share.sharedWithUser.firstName, share.sharedWithUser.lastName].filter(Boolean).join(' ')} subtitle={`@${share.sharedWithUser.username} • ${t(`session.sharing.${share.accessLevel === 'view' ? 'viewOnly' : share.accessLevel === 'edit' ? 'canEdit' : 'canManage'}`)}`} - icon={<Ionicons name="person-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="person-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => setShowShareDialog(true)} /> )) ) : ( <Item title={t('session.sharing.noShares')} - icon={<Ionicons name="people-outline" size={29} color="#8E8E93" />} + icon={<Ionicons name="people-outline" size={29} color={theme.colors.textSecondary} />} showChevron={false} /> )} {canManage && ( <Item title={t('session.sharing.addShare')} - icon={<Ionicons name="person-add-outline" size={29} color="#34C759" />} + icon={<Ionicons name="person-add-outline" size={29} color={theme.colors.success} />} onPress={() => setShowFriendSelector(true)} /> )} @@ -294,14 +312,14 @@ function SharingManagementContent({ sessionId }: { sessionId: string }) { ? t('session.sharing.expiresOn') + ': ' + new Date(publicShare.expiresAt).toLocaleDateString() : t('session.sharing.never') } - icon={<Ionicons name="link-outline" size={29} color="#34C759" />} + icon={<Ionicons name="link-outline" size={29} color={theme.colors.success} />} onPress={() => setShowPublicLinkDialog(true)} /> ) : ( <Item title={t('session.sharing.createPublicLink')} subtitle={t('session.sharing.publicLinkDescription')} - icon={<Ionicons name="link-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="link-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => setShowPublicLinkDialog(true)} /> )} diff --git a/apps/ui/sources/app/(app)/session/archived.tsx b/apps/ui/sources/app/(app)/session/archived.tsx index a06fba46a..c439324f0 100644 --- a/apps/ui/sources/app/(app)/session/archived.tsx +++ b/apps/ui/sources/app/(app)/session/archived.tsx @@ -4,7 +4,7 @@ import { Ionicons } from '@expo/vector-icons'; import { StyleSheet } from 'react-native-unistyles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { layout } from '@/components/ui/layout/layout'; import { Typography } from '@/constants/Typography'; import { Avatar } from '@/components/ui/avatar/Avatar'; diff --git a/apps/ui/sources/app/(app)/session/recent.tsx b/apps/ui/sources/app/(app)/session/recent.tsx index f743e5e07..c95b78460 100644 --- a/apps/ui/sources/app/(app)/session/recent.tsx +++ b/apps/ui/sources/app/(app)/session/recent.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, FlatList } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { useAllSessions } from '@/sync/domains/state/storage'; import { Session } from '@/sync/domains/state/storageTypes'; import { Avatar } from '@/components/ui/avatar/Avatar'; diff --git a/apps/ui/sources/app/(app)/settings/account.encryptionModeToggle.test.tsx b/apps/ui/sources/app/(app)/settings/account.encryptionModeToggle.test.tsx new file mode 100644 index 000000000..a2736be85 --- /dev/null +++ b/apps/ui/sources/app/(app)/settings/account.encryptionModeToggle.test.tsx @@ -0,0 +1,219 @@ +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import { storage } from '@/sync/domains/state/storageStore'; +import { profileDefaults } from '@/sync/domains/profiles/profile'; + +import { + createAccountFeaturesResponse, + getRequestUrl, + isFeaturesRequest, +} from './account.testHelpers'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native-reanimated', () => ({})); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn(), back: vi.fn() }), +})); + +const useFeatureEnabledMock = vi.hoisted(() => vi.fn()); +vi.mock('@/hooks/server/useFeatureEnabled', () => ({ + useFeatureEnabled: (featureId: string) => useFeatureEnabledMock(featureId), +})); + +vi.mock('expo-camera', () => ({ + useCameraPermissions: () => [{ granted: true }, async () => ({ granted: true })], + CameraView: { + isModernBarcodeScannerAvailable: false, + onModernBarcodeScanned: () => ({ remove: () => {} }), + launchScanner: () => {}, + dismissScanner: async () => {}, + }, +})); + +vi.mock('@/auth/context/AuthContext', () => ({ + useAuth: () => ({ + isAuthenticated: true, + credentials: { token: 't', secret: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }, + logout: vi.fn(), + }), +})); + +describe('Settings → Account (encryption mode toggle)', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('does not fetch account encryption mode when the feature gate is disabled', async () => { + useFeatureEnabledMock.mockReturnValue(false); + storage.getState().applyProfile({ ...profileDefaults, linkedProviders: [], username: null }); + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = getRequestUrl(input); + if (isFeaturesRequest(url)) { + return { + ok: true, + json: async () => createAccountFeaturesResponse({ encryptionAccountOptOutEnabled: false }), + }; + } + throw new Error(`Unexpected fetch: ${url} (${init?.method ?? 'GET'})`); + }); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + + const { default: AccountScreen } = await import('./account'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<AccountScreen />); + }); + await act(async () => {}); + + const encryptionItems = + tree?.root.findAll( + (node) => + node?.props?.rightElement?.props?.testID === 'settings-account-encryption-mode-switch' && + typeof node?.props?.rightElement?.props?.onValueChange === 'function', + ) ?? []; + expect(encryptionItems).toHaveLength(0); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); + + it('fetches + updates account encryption mode when enabled', async () => { + useFeatureEnabledMock.mockReturnValue(true); + storage.getState().applyProfile({ ...profileDefaults, linkedProviders: [], username: null }); + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = getRequestUrl(input); + const method = (init?.method ?? 'GET').toUpperCase(); + if (isFeaturesRequest(url)) { + return { + ok: true, + json: async () => createAccountFeaturesResponse({ encryptionAccountOptOutEnabled: true }), + }; + } + if (url.endsWith('/v1/account/encryption') && method === 'GET') { + return { + ok: true, + json: async () => ({ mode: 'e2ee', updatedAt: 1 }), + }; + } + if (url.endsWith('/v1/account/encryption') && method === 'PATCH') { + const body = init?.body ? JSON.parse(String(init.body)) : null; + expect(body).toEqual({ mode: 'plain' }); + return { + ok: true, + json: async () => ({ mode: 'plain', updatedAt: 2 }), + }; + } + throw new Error(`Unexpected fetch: ${url} (${method})`); + }); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + + const { default: AccountScreen } = await import('./account'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<AccountScreen />); + }); + await act(async () => {}); + + const encryptionItems = + tree?.root.findAll( + (node) => + node?.props?.rightElement?.props?.testID === 'settings-account-encryption-mode-switch' && + typeof node?.props?.rightElement?.props?.onValueChange === 'function', + ) ?? []; + expect(encryptionItems).toHaveLength(1); + + await act(async () => { + encryptionItems[0]!.props.rightElement.props.onValueChange(false); + }); + + const seen = fetchMock.mock.calls.map((call) => [getRequestUrl(call[0]), (call[1]?.method ?? 'GET').toUpperCase()]); + expect(seen).toEqual( + expect.arrayContaining([ + [expect.stringContaining('/v1/account/encryption'), 'GET'], + [expect.stringContaining('/v1/account/encryption'), 'PATCH'], + ]), + ); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); + + it('shows an error alert when updating account encryption mode fails', async () => { + useFeatureEnabledMock.mockReturnValue(true); + storage.getState().applyProfile({ ...profileDefaults, linkedProviders: [], username: null }); + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = getRequestUrl(input); + const method = (init?.method ?? 'GET').toUpperCase(); + if (isFeaturesRequest(url)) { + return { + ok: true, + json: async () => createAccountFeaturesResponse({ encryptionAccountOptOutEnabled: true }), + }; + } + if (url.endsWith('/v1/account/encryption') && method === 'GET') { + return { + ok: true, + json: async () => ({ mode: 'e2ee', updatedAt: 1 }), + }; + } + if (url.endsWith('/v1/account/encryption') && method === 'PATCH') { + return { + ok: false, + status: 404, + json: async () => ({ error: 'not-found' }), + }; + } + throw new Error(`Unexpected fetch: ${url} (${method})`); + }); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + + const { Modal } = await import('@/modal'); + const alertSpy = vi.spyOn(Modal, 'alert').mockResolvedValue(); + + const { default: AccountScreen } = await import('./account'); + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<AccountScreen />); + }); + await act(async () => {}); + + const encryptionItems = + tree?.root.findAll( + (node) => + node?.props?.rightElement?.props?.testID === 'settings-account-encryption-mode-switch' && + typeof node?.props?.rightElement?.props?.onValueChange === 'function', + ) ?? []; + expect(encryptionItems).toHaveLength(1); + + await act(async () => { + await encryptionItems[0]!.props.rightElement.props.onValueChange(false); + }); + + expect(alertSpy).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Encryption opt-out is not enabled on this server'), + ); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); +}); diff --git a/apps/ui/sources/app/(app)/settings/account.testHelpers.ts b/apps/ui/sources/app/(app)/settings/account.testHelpers.ts index 7a84b69d7..3536722da 100644 --- a/apps/ui/sources/app/(app)/settings/account.testHelpers.ts +++ b/apps/ui/sources/app/(app)/settings/account.testHelpers.ts @@ -3,6 +3,8 @@ import { createWelcomeFeaturesResponse } from '../index.testHelpers'; type AccountFeaturesOverrides = { friendsEnabled?: boolean; friendsAllowUsername?: boolean; + encryptionPlaintextStorageEnabled?: boolean; + encryptionAccountOptOutEnabled?: boolean; }; export function createAccountFeaturesResponse( @@ -20,6 +22,14 @@ export function createAccountFeaturesResponse( ...base, features: { ...base.features, + encryption: { + plaintextStorage: { + enabled: overrides.encryptionPlaintextStorageEnabled ?? (overrides.encryptionAccountOptOutEnabled ?? false), + }, + accountOptOut: { + enabled: overrides.encryptionAccountOptOutEnabled ?? false, + }, + }, social: { friends: { enabled: overrides.friendsEnabled ?? true, @@ -28,6 +38,13 @@ export function createAccountFeaturesResponse( }, capabilities: { ...base.capabilities, + encryption: { + storagePolicy: (overrides.encryptionPlaintextStorageEnabled ?? (overrides.encryptionAccountOptOutEnabled ?? false)) + ? 'optional' + : 'required_e2ee', + allowAccountOptOut: overrides.encryptionAccountOptOutEnabled ?? false, + defaultAccountMode: 'e2ee', + }, social: { ...base.capabilities.social, friends: { diff --git a/apps/ui/sources/app/(app)/settings/account.tsx b/apps/ui/sources/app/(app)/settings/account.tsx index a402fa5e8..6afc81729 100644 --- a/apps/ui/sources/app/(app)/settings/account.tsx +++ b/apps/ui/sources/app/(app)/settings/account.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { View, Text, Pressable, Platform } from 'react-native'; +import { View, Pressable, Platform } from 'react-native'; import { useAuth } from '@/auth/context/AuthContext'; import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; @@ -28,6 +28,10 @@ import { useFriendsEnabled } from '@/hooks/server/useFriendsEnabled'; import { useFriendsIdentityReadiness } from '@/hooks/server/useFriendsIdentityReadiness'; import { ProviderIdentityItems } from '@/components/account/ProviderIdentityItems'; import { isLegacyAuthCredentials } from '@/auth/storage/tokenStorage'; +import { useFeatureEnabled } from '@/hooks/server/useFeatureEnabled'; +import { fetchAccountEncryptionMode, updateAccountEncryptionMode } from '@/sync/api/account/apiAccountEncryptionMode'; +import { Text } from '@/components/ui/text/Text'; + export default React.memo(() => { const { theme } = useUnistyles(); @@ -40,6 +44,11 @@ export default React.memo(() => { const friendsIdentityReadiness = useFriendsIdentityReadiness(); const friendsEnabled = useFriendsEnabled(); const applyProfile = storage((state) => state.applyProfile); + const encryptionAccountOptOutEnabled = useFeatureEnabled('encryption.accountOptOut'); + + const [accountEncryptionMode, setAccountEncryptionMode] = useState<'e2ee' | 'plain' | null>(null); + const [accountEncryptionModeLoading, setAccountEncryptionModeLoading] = useState(false); + const [accountEncryptionModeSaving, setAccountEncryptionModeSaving] = useState(false); // Get the current secret key const legacySecret = @@ -55,6 +64,28 @@ export default React.memo(() => { !friendsIdentityReadiness.isLoadingFeatures && friendsIdentityReadiness.gate.gateVariant === 'username'; + React.useEffect(() => { + if (!encryptionAccountOptOutEnabled) return; + const credentials = auth.credentials; + if (!credentials?.token) return; + + let cancelled = false; + setAccountEncryptionModeLoading(true); + fetchAccountEncryptionMode(credentials) + .then((res) => { + if (cancelled) return; + setAccountEncryptionMode(res.mode); + }) + .finally(() => { + if (cancelled) return; + setAccountEncryptionModeLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [auth.credentials?.token, encryptionAccountOptOutEnabled]); + const [savingUsername, saveUsername] = useHappyAction(async () => { if (!auth.credentials) return; if (!canSetUsername) return; @@ -165,7 +196,7 @@ export default React.memo(() => { <Item title={t('settingsAccount.linkNewDevice')} subtitle={isConnecting ? t('common.scanning') : t('settingsAccount.linkNewDeviceSubtitle')} - icon={<Ionicons name="qr-code-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="qr-code-outline" size={29} color={theme.colors.accent.blue} />} onPress={connectAccount} disabled={isConnecting} showChevron={false} @@ -256,18 +287,19 @@ export default React.memo(() => { {formattedSecret ? ( <ItemGroup title={t('settingsAccount.backup')} footer={t('settingsAccount.backupDescription')}> <Item + testID="settings-account-secret-key-item" title={t('settingsAccount.secretKey')} subtitle={showSecret ? t('settingsAccount.tapToHide') : t('settingsAccount.tapToReveal')} icon={ <Ionicons name={showSecret ? 'eye-off-outline' : 'eye-outline'} size={29} - color="#FF9500" + color={theme.colors.accent.orange} /> } onPress={handleShowSecret} rightElement={ - <Pressable onPress={handleCopySecret} hitSlop={12}> + <Pressable testID="settings-account-secret-key-copy" onPress={handleCopySecret} hitSlop={12}> <Ionicons name="copy-outline" size={18} @@ -283,7 +315,7 @@ export default React.memo(() => { {/* Secret Key Display */} {formattedSecret && showSecret && ( <ItemGroup> - <Pressable onPress={handleCopySecret}> + <Pressable testID="settings-account-secret-key-revealed" onPress={handleCopySecret}> <View style={{ backgroundColor: theme.colors.surface, paddingHorizontal: 16, @@ -315,7 +347,7 @@ export default React.memo(() => { color: theme.colors.text, ...Typography.mono() }}> - {formattedSecret} + <Text testID="settings-account-secret-key-value">{formattedSecret}</Text> </Text> </View> </Pressable> @@ -323,6 +355,47 @@ export default React.memo(() => { )} {/* Analytics Section */} + {encryptionAccountOptOutEnabled && ( + <ItemGroup title={t('terminal.encryption')}> + <Item + title={t('terminal.endToEndEncrypted')} + rightElement={ + <Switch + testID="settings-account-encryption-mode-switch" + value={(accountEncryptionMode ?? 'e2ee') === 'e2ee'} + disabled={ + accountEncryptionModeLoading || + accountEncryptionModeSaving || + !auth.credentials || + accountEncryptionMode == null + } + onValueChange={async (enabled) => { + if (!auth.credentials) return; + if (accountEncryptionMode == null) return; + + setAccountEncryptionModeSaving(true); + try { + const nextMode = enabled ? 'e2ee' : 'plain'; + const next = await updateAccountEncryptionMode(auth.credentials, nextMode); + setAccountEncryptionMode(next.mode); + } catch (e) { + if (e instanceof HappyError) { + await Modal.alert(t('common.error'), e.message); + return; + } + await Modal.alert(t('common.error'), 'Failed to update encryption setting'); + return; + } finally { + setAccountEncryptionModeSaving(false); + } + }} + /> + } + showChevron={false} + /> + </ItemGroup> + )} + <ItemGroup title={t('settingsAccount.privacy')} footer={t('settingsAccount.privacyDescription')} @@ -337,8 +410,11 @@ export default React.memo(() => { const optOut = !value; setAnalyticsOptOut(optOut); }} - trackColor={{ false: '#767577', true: '#34C759' }} - thumbColor="#FFFFFF" + trackColor={{ + false: theme.colors.switch.track.inactive, + true: theme.colors.switch.track.active, + }} + thumbColor={!analyticsOptOut ? theme.colors.switch.thumb.active : theme.colors.switch.thumb.inactive} /> } showChevron={false} @@ -350,7 +426,7 @@ export default React.memo(() => { <Item title={t('settingsAccount.logout')} subtitle={t('settingsAccount.logoutSubtitle')} - icon={<Ionicons name="log-out-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="log-out-outline" size={29} color={theme.colors.textDestructive} />} destructive onPress={handleLogout} /> diff --git a/apps/ui/sources/app/(app)/settings/appearance.sessionList.spec.tsx b/apps/ui/sources/app/(app)/settings/appearance.sessionList.spec.tsx index 95cb14af3..fed706773 100644 --- a/apps/ui/sources/app/(app)/settings/appearance.sessionList.spec.tsx +++ b/apps/ui/sources/app/(app)/settings/appearance.sessionList.spec.tsx @@ -24,18 +24,6 @@ vi.mock('expo-localization', () => ({ getLocales: () => [{ languageTag: 'en-US' }], })); -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => ({ - theme: { colors: { status: { connecting: '#00f' } } }, - }), - UnistylesRuntime: { - setAdaptiveThemes: vi.fn(), - setTheme: vi.fn(), - setRootViewBackgroundColor: vi.fn(), - }, -})); - vi.mock('expo-system-ui', () => ({ setBackgroundColorAsync: vi.fn(), })); @@ -48,6 +36,10 @@ vi.mock('@/theme', () => ({ vi.mock('react-native', () => ({ View: 'View', Text: 'Text', + Pressable: 'Pressable', + AppState: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, Platform: { OS: 'ios', select: (spec: Record<string, unknown>) => @@ -131,6 +123,7 @@ describe('AppearanceSettingsScreen (session list controls)', () => { useLocalSettingMutableMock.mockImplementation((key: string) => { if (key === 'themePreference') return createNoopMutable('adaptive' as any); + if (key === 'uiFontScale') return createNoopMutable(1 as any); return createNoopMutable(null); }); }); @@ -149,5 +142,6 @@ describe('AppearanceSettingsScreen (session list controls)', () => { expect(titles).toContain('settingsFeatures.hideInactiveSessions'); expect(titles).toContain('settingsFeatures.sessionListActiveGrouping'); expect(titles).toContain('settingsFeatures.sessionListInactiveGrouping'); + expect(titles).toContain('settingsAppearance.textSize'); }); }); diff --git a/apps/ui/sources/app/(app)/settings/appearance.tsx b/apps/ui/sources/app/(app)/settings/appearance.tsx index e48387a63..cf9e60e95 100644 --- a/apps/ui/sources/app/(app)/settings/appearance.tsx +++ b/apps/ui/sources/app/(app)/settings/appearance.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Ionicons } from '@expo/vector-icons'; +import { View } from 'react-native'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { ItemList } from '@/components/ui/lists/ItemList'; @@ -8,7 +9,7 @@ import { useRouter } from 'expo-router'; import * as Localization from 'expo-localization'; import { useUnistyles, UnistylesRuntime } from 'react-native-unistyles'; import { Switch } from '@/components/ui/forms/Switch'; -import { DropdownMenu, type DropdownMenuItem } from '@/components/ui/forms/dropdown/DropdownMenu'; +import { DropdownMenu } from '@/components/ui/forms/dropdown/DropdownMenu'; import { Appearance } from 'react-native'; import * as SystemUI from 'expo-system-ui'; import { darkTheme, lightTheme } from '@/theme'; @@ -40,10 +41,12 @@ export default React.memo(function AppearanceSettingsScreen() { const [sessionListActiveGroupingV1, setSessionListActiveGroupingV1] = useSettingMutable('sessionListActiveGroupingV1'); const [sessionListInactiveGroupingV1, setSessionListInactiveGroupingV1] = useSettingMutable('sessionListInactiveGroupingV1'); const [themePreference, setThemePreference] = useLocalSettingMutable('themePreference'); + const [uiFontScale, setUiFontScale] = useLocalSettingMutable('uiFontScale'); const [preferredLanguage] = useSettingMutable('preferredLanguage'); const [openGroupingMenu, setOpenGroupingMenu] = React.useState<null | 'active' | 'inactive'>(null); + const [openTextSizeMenu, setOpenTextSizeMenu] = React.useState(false); - const groupingMenuItems = React.useMemo((): DropdownMenuItem[] => { + const groupingMenuItems = React.useMemo(() => { return [ { id: 'project', @@ -66,7 +69,51 @@ export default React.memo(function AppearanceSettingsScreen() { } setSessionListInactiveGroupingV1(itemId); }, [setSessionListActiveGroupingV1, setSessionListInactiveGroupingV1]); - + + const uiFontScalePresets = React.useMemo(() => { + return { + xxsmall: 0.8, + xsmall: 0.85, + small: 0.93, + default: 1, + large: 1.1, + xlarge: 1.2, + xxlarge: 1.3, + } as const; + }, []); + + const textSizeMenuItems = React.useMemo(() => { + return [ + { id: 'xxsmall', title: t('settingsAppearance.textSizeOptions.xxsmall') }, + { id: 'xsmall', title: t('settingsAppearance.textSizeOptions.xsmall') }, + { id: 'small', title: t('settingsAppearance.textSizeOptions.small') }, + { id: 'default', title: t('settingsAppearance.textSizeOptions.default') }, + { id: 'large', title: t('settingsAppearance.textSizeOptions.large') }, + { id: 'xlarge', title: t('settingsAppearance.textSizeOptions.xlarge') }, + { id: 'xxlarge', title: t('settingsAppearance.textSizeOptions.xxlarge') }, + ]; + }, []); + + const selectedTextSizeId = React.useMemo(() => { + const entries = Object.entries(uiFontScalePresets) as Array<[keyof typeof uiFontScalePresets, number]>; + let best: keyof typeof uiFontScalePresets = 'default'; + let bestDist = Number.POSITIVE_INFINITY; + for (const [id, scale] of entries) { + const dist = Math.abs((uiFontScale ?? 1) - scale); + if (dist < bestDist) { + bestDist = dist; + best = id; + } + } + return best; + }, [uiFontScale, uiFontScalePresets]); + + const selectUiFontSize = React.useCallback((itemId: string) => { + const next = (uiFontScalePresets as any)[itemId]; + if (typeof next !== 'number') return; + setUiFontScale(next as any); + }, [setUiFontScale, uiFontScalePresets]); + // Ensure we have a valid style for display, defaulting to gradient for unknown values const displayStyle: KnownAvatarStyle = isKnownAvatarStyle(avatarStyle) ? avatarStyle : 'gradient'; @@ -126,18 +173,41 @@ export default React.memo(function AppearanceSettingsScreen() { <ItemGroup title={t('settingsLanguage.title')} footer={t('settingsLanguage.description')}> <Item title={t('settingsLanguage.currentLanguage')} - icon={<Ionicons name="language-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="language-outline" size={29} color={theme.colors.accent.blue} />} detail={getLanguageDisplayText()} onPress={() => router.push('/settings/language')} /> </ItemGroup> + {/* Text Settings */} + <ItemGroup title={t('settingsAppearance.text')} footer={t('settingsAppearance.textDescription')}> + <DropdownMenu + open={openTextSizeMenu} + onOpenChange={setOpenTextSizeMenu} + variant="selectable" + search={false} + selectedId={selectedTextSizeId as any} + showCategoryTitles={false} + matchTriggerWidth={true} + connectToTrigger={true} + rowKind="item" + itemTrigger={{ + title: t('settingsAppearance.textSize'), + subtitle: t('settingsAppearance.textSizeDescription'), + icon: <Ionicons name="text-outline" size={29} color={theme.colors.accent.orange} />, + showSelectedSubtitle: false, + }} + items={textSizeMenuItems as any} + onSelect={selectUiFontSize} + /> + </ItemGroup> + {/* Text Settings */} {/* <ItemGroup title="Text" footer="Adjust text size and font preferences"> <Item title="Text Size" subtitle="Make text larger or smaller" - icon={<Ionicons name="text-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="text-outline" size={29} color={theme.colors.accent.orange} />} detail="Default" onPress={() => { }} disabled @@ -145,7 +215,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title="Font" subtitle="Choose your preferred font" - icon={<Ionicons name="text-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="text-outline" size={29} color={theme.colors.accent.orange} />} detail="System" onPress={() => { }} disabled @@ -157,7 +227,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.compactSessionView')} subtitle={t('settingsAppearance.compactSessionViewDescription')} - icon={<Ionicons name="albums-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="albums-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={compactSessionView} @@ -169,7 +239,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.compactSessionViewMinimal')} subtitle={t('settingsAppearance.compactSessionViewMinimalDescription')} - icon={<Ionicons name="remove-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="remove-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={compactSessionViewMinimal} @@ -181,7 +251,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsFeatures.hideInactiveSessions')} subtitle={t('settingsFeatures.hideInactiveSessionsSubtitle')} - icon={<Ionicons name="eye-off-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="eye-off-outline" size={29} color={theme.colors.accent.orange} />} rightElement={ <Switch value={hideInactiveSessions} @@ -203,7 +273,7 @@ export default React.memo(function AppearanceSettingsScreen() { itemTrigger={{ title: t('settingsFeatures.sessionListActiveGrouping'), subtitle: t('settingsFeatures.sessionListActiveGroupingSubtitle'), - icon: <Ionicons name="folder-open-outline" size={29} color="#007AFF" />, + icon: <Ionicons name="folder-open-outline" size={29} color={theme.colors.accent.blue} />, showSelectedSubtitle: false, }} items={groupingMenuItems} @@ -222,7 +292,7 @@ export default React.memo(function AppearanceSettingsScreen() { itemTrigger={{ title: t('settingsFeatures.sessionListInactiveGrouping'), subtitle: t('settingsFeatures.sessionListInactiveGroupingSubtitle'), - icon: <Ionicons name="calendar-outline" size={29} color="#34C759" />, + icon: <Ionicons name="calendar-outline" size={29} color={theme.colors.success} />, showSelectedSubtitle: false, }} items={groupingMenuItems} @@ -231,7 +301,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.inlineToolCalls')} subtitle={t('settingsAppearance.inlineToolCallsDescription')} - icon={<Ionicons name="code-slash-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="code-slash-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={viewInline} @@ -242,7 +312,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.expandTodoLists')} subtitle={t('settingsAppearance.expandTodoListsDescription')} - icon={<Ionicons name="checkmark-done-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="checkmark-done-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={expandTodos} @@ -253,7 +323,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.showLineNumbersInDiffs')} subtitle={t('settingsAppearance.showLineNumbersInDiffsDescription')} - icon={<Ionicons name="list-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="list-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={showLineNumbers} @@ -264,7 +334,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.showLineNumbersInToolViews')} subtitle={t('settingsAppearance.showLineNumbersInToolViewsDescription')} - icon={<Ionicons name="code-working-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="code-working-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={showLineNumbersInToolViews} @@ -275,7 +345,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.wrapLinesInDiffs')} subtitle={t('settingsAppearance.wrapLinesInDiffsDescription')} - icon={<Ionicons name="return-down-forward-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="return-down-forward-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={wrapLinesInDiffs} @@ -286,7 +356,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.alwaysShowContextSize')} subtitle={t('settingsAppearance.alwaysShowContextSizeDescription')} - icon={<Ionicons name="analytics-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="analytics-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={alwaysShowContextSize} @@ -297,7 +367,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.agentInputActionBarLayout')} subtitle={t('settingsAppearance.agentInputActionBarLayoutDescription')} - icon={<Ionicons name="menu-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="menu-outline" size={29} color={theme.colors.accent.indigo} />} detail={ agentInputActionBarLayout === 'auto' ? t('settingsAppearance.agentInputActionBarLayoutOptions.auto') @@ -317,7 +387,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.agentInputChipDensity')} subtitle={t('settingsAppearance.agentInputChipDensityDescription')} - icon={<Ionicons name="text-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="text-outline" size={29} color={theme.colors.accent.indigo} />} detail={ agentInputChipDensity === 'auto' ? t('settingsAppearance.agentInputChipDensityOptions.auto') @@ -335,7 +405,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.avatarStyle')} subtitle={t('settingsAppearance.avatarStyleDescription')} - icon={<Ionicons name="person-circle-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="person-circle-outline" size={29} color={theme.colors.accent.indigo} />} detail={displayStyle === 'pixelated' ? t('settingsAppearance.avatarOptions.pixelated') : displayStyle === 'brutalist' ? t('settingsAppearance.avatarOptions.brutalist') : t('settingsAppearance.avatarOptions.gradient')} onPress={() => { const currentIndex = displayStyle === 'pixelated' ? 0 : displayStyle === 'gradient' ? 1 : 2; @@ -347,7 +417,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title={t('settingsAppearance.showFlavorIcons')} subtitle={t('settingsAppearance.showFlavorIconsDescription')} - icon={<Ionicons name="apps-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="apps-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={showFlavorIcons} @@ -358,7 +428,7 @@ export default React.memo(function AppearanceSettingsScreen() { {/* <Item title="Compact Mode" subtitle="Reduce spacing between elements" - icon={<Ionicons name="contract-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="contract-outline" size={29} color={theme.colors.accent.indigo} />} disabled rightElement={ <Switch @@ -370,7 +440,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title="Show Avatars" subtitle="Display user and assistant avatars" - icon={<Ionicons name="person-circle-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="person-circle-outline" size={29} color={theme.colors.accent.indigo} />} disabled rightElement={ <Switch @@ -386,7 +456,7 @@ export default React.memo(function AppearanceSettingsScreen() { <Item title="Accent Color" subtitle="Choose your accent color" - icon={<Ionicons name="color-palette-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="color-palette-outline" size={29} color={theme.colors.warningCritical} />} detail="Blue" onPress={() => { }} disabled diff --git a/apps/ui/sources/app/(app)/settings/features.tsx b/apps/ui/sources/app/(app)/settings/features.tsx index 85bbcbb88..56f2b8df8 100644 --- a/apps/ui/sources/app/(app)/settings/features.tsx +++ b/apps/ui/sources/app/(app)/settings/features.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { FEATURE_IDS, featureRequiresServerSnapshot, @@ -26,6 +27,7 @@ import { useEffectiveServerSelection } from '@/hooks/server/useEffectiveServerSe import { useServerFeaturesMainSelectionSnapshot } from '@/sync/domains/features/featureDecisionRuntime'; export default React.memo(function FeaturesSettingsScreen() { + const { theme } = useUnistyles(); const [experiments, setExperiments] = useSettingMutable('experiments'); const [featureToggles, setFeatureToggles] = useSettingMutable('featureToggles'); const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); @@ -40,6 +42,43 @@ export default React.memo(function FeaturesSettingsScreen() { const toggleDefinitions = React.useMemo(() => listUiFeatureToggleDefinitions(), []); const selection = useEffectiveServerSelection(); + const resolveLegacyIconColor = React.useCallback((color: string): string => { + const normalized = String(color).trim().toUpperCase(); + switch (normalized) { + case '#007AFF': + case '#0A84FF': + return theme.colors.accent.blue; + case '#34C759': + case '#32D74B': + return theme.colors.success; + case '#FF9500': + case '#FF9F0A': + return theme.colors.accent.orange; + case '#AF52DE': + case '#BF5AF2': + return theme.colors.accent.purple; + case '#5856D6': + case '#5E5CE6': + return theme.colors.accent.indigo; + case '#FF3B30': + case '#FF453A': + return theme.colors.warningCritical; + case '#FFCC00': + case '#FFD60A': + return theme.colors.accent.yellow; + default: + return color; + } + }, [ + theme.colors.accent.blue, + theme.colors.accent.indigo, + theme.colors.accent.orange, + theme.colors.accent.purple, + theme.colors.accent.yellow, + theme.colors.success, + theme.colors.warningCritical, + ]); + const shouldProbeServerForToggleVisibility = React.useMemo(() => { for (const def of toggleDefinitions) { if (getFeatureBuildPolicyDecision(def.featureId) === 'deny') continue; @@ -199,14 +238,14 @@ export default React.memo(function FeaturesSettingsScreen() { <Item title={t('settingsFeatures.markdownCopyV2')} subtitle={t('settingsFeatures.markdownCopyV2Subtitle')} - icon={<Ionicons name="text-outline" size={29} color="#34C759" />} + icon={<Ionicons name="text-outline" size={29} color={theme.colors.success} />} rightElement={<Switch value={markdownCopyV2} onValueChange={setMarkdownCopyV2} />} showChevron={false} /> <Item title={t('settingsFeatures.environmentBadge')} subtitle={t('settingsFeatures.environmentBadgeSubtitle')} - icon={<Ionicons name="pricetag-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="pricetag-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={<Switch value={showEnvironmentBadge} onValueChange={setShowEnvironmentBadge} />} showChevron={false} /> @@ -215,21 +254,21 @@ export default React.memo(function FeaturesSettingsScreen() { subtitle={useEnhancedSessionWizard ? t('settingsFeatures.enhancedSessionWizardEnabled') : t('settingsFeatures.enhancedSessionWizardDisabled')} - icon={<Ionicons name="sparkles-outline" size={29} color="#AF52DE" />} + icon={<Ionicons name="sparkles-outline" size={29} color={theme.colors.accent.purple} />} rightElement={<Switch value={useEnhancedSessionWizard} onValueChange={setUseEnhancedSessionWizard} />} showChevron={false} /> <Item title={t('settingsFeatures.machinePickerSearch')} subtitle={t('settingsFeatures.machinePickerSearchSubtitle')} - icon={<Ionicons name="search-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="search-outline" size={29} color={theme.colors.accent.blue} />} rightElement={<Switch value={useMachinePickerSearch} onValueChange={setUseMachinePickerSearch} />} showChevron={false} /> <Item title={t('settingsFeatures.pathPickerSearch')} subtitle={t('settingsFeatures.pathPickerSearchSubtitle')} - icon={<Ionicons name="folder-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="folder-outline" size={29} color={theme.colors.accent.blue} />} rightElement={<Switch value={usePathPickerSearch} onValueChange={setUsePathPickerSearch} />} showChevron={false} /> @@ -238,7 +277,7 @@ export default React.memo(function FeaturesSettingsScreen() { subtitle={useProfiles ? t('settingsFeatures.profilesEnabled') : t('settingsFeatures.profilesDisabled')} - icon={<Ionicons name="person-outline" size={29} color="#AF52DE" />} + icon={<Ionicons name="person-outline" size={29} color={theme.colors.accent.purple} />} rightElement={<Switch value={useProfiles} onValueChange={setUseProfiles} />} showChevron={false} /> @@ -253,7 +292,7 @@ export default React.memo(function FeaturesSettingsScreen() { <Item title={t('settingsFeatures.commandPalette')} subtitle={commandPaletteEnabled ? t('settingsFeatures.commandPaletteEnabled') : t('settingsFeatures.commandPaletteDisabled')} - icon={<Ionicons name="keypad-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="keypad-outline" size={29} color={theme.colors.accent.blue} />} rightElement={<Switch value={commandPaletteEnabled} onValueChange={setCommandPaletteEnabled} />} showChevron={false} /> @@ -268,7 +307,7 @@ export default React.memo(function FeaturesSettingsScreen() { <Item title={t('settingsFeatures.experimentalFeatures')} subtitle={experiments ? t('settingsFeatures.experimentalFeaturesEnabled') : t('settingsFeatures.experimentalFeaturesDisabled')} - icon={<Ionicons name="flask-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="flask-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={experiments} @@ -299,7 +338,7 @@ export default React.memo(function FeaturesSettingsScreen() { key={d.featureId} title={t(d.titleKey)} subtitle={t(d.subtitleKey)} - icon={<Ionicons name={d.icon.ioniconName as keyof typeof Ionicons.glyphMap} size={29} color={d.icon.color} />} + icon={<Ionicons name={d.icon.ioniconName as keyof typeof Ionicons.glyphMap} size={29} color={resolveLegacyIconColor(d.icon.color)} />} rightElement={ <Switch value={enabled} @@ -328,7 +367,7 @@ export default React.memo(function FeaturesSettingsScreen() { key={d.featureId} title={t(d.titleKey)} subtitle={t(d.subtitleKey)} - icon={<Ionicons name={d.icon.ioniconName as keyof typeof Ionicons.glyphMap} size={29} color={d.icon.color} />} + icon={<Ionicons name={d.icon.ioniconName as keyof typeof Ionicons.glyphMap} size={29} color={resolveLegacyIconColor(d.icon.color)} />} rightElement={ <Switch value={enabled} diff --git a/apps/ui/sources/app/(app)/settings/language.tsx b/apps/ui/sources/app/(app)/settings/language.tsx index 25bf03b9a..0d62ead56 100644 --- a/apps/ui/sources/app/(app)/settings/language.tsx +++ b/apps/ui/sources/app/(app)/settings/language.tsx @@ -85,14 +85,14 @@ export default function LanguageSettingsScreen() { icon={<Ionicons name="language-outline" size={29} - color="#007AFF" + color={theme.colors.accent.blue} />} rightElement={ currentSelection === option.key ? ( <Ionicons name="checkmark" size={20} - color="#007AFF" + color={theme.colors.accent.blue} /> ) : null } @@ -103,4 +103,4 @@ export default function LanguageSettingsScreen() { </ItemGroup> </ItemList> ); -} \ No newline at end of file +} diff --git a/apps/ui/sources/app/(app)/settings/memory.enableSwitch.test.tsx b/apps/ui/sources/app/(app)/settings/memory.enableSwitch.test.tsx index a6fad86dd..1b65477f4 100644 --- a/apps/ui/sources/app/(app)/settings/memory.enableSwitch.test.tsx +++ b/apps/ui/sources/app/(app)/settings/memory.enableSwitch.test.tsx @@ -20,21 +20,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - text: '#111', - textSecondary: '#666', - status: { connected: '#0a0', disconnected: '#a00' }, - divider: '#eee', - input: { placeholder: '#999', background: '#fff' }, - }, - }, - }), - StyleSheet: { create: (v: any) => v, absoluteFillObject: {} }, -})); - vi.mock('@/text', () => ({ t: (key: string) => key, })); @@ -63,8 +48,9 @@ vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ DropdownMenu: (props: any) => React.createElement('DropdownMenu', props), })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/sync/domains/state/storage', () => ({ diff --git a/apps/ui/sources/app/(app)/settings/profiles.tsx b/apps/ui/sources/app/(app)/settings/profiles.tsx index d0fb2d037..2d3b0671a 100644 --- a/apps/ui/sources/app/(app)/settings/profiles.tsx +++ b/apps/ui/sources/app/(app)/settings/profiles.tsx @@ -305,7 +305,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel <Item title={t('settingsFeatures.profiles')} subtitle={t('settingsFeatures.profilesDisabled')} - icon={<Ionicons name="person-outline" size={29} color="#AF52DE" />} + icon={<Ionicons name="person-outline" size={29} color={theme.colors.accent.purple} />} rightElement={ <Switch value={useProfiles} diff --git a/apps/ui/sources/app/(app)/settings/providers/[providerId].tsx b/apps/ui/sources/app/(app)/settings/providers/[providerId].tsx index a9e5734df..0ac4f3edd 100644 --- a/apps/ui/sources/app/(app)/settings/providers/[providerId].tsx +++ b/apps/ui/sources/app/(app)/settings/providers/[providerId].tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Platform, TextInput, View } from 'react-native'; +import { Platform, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams } from 'expo-router'; import { useUnistyles, StyleSheet } from 'react-native-unistyles'; @@ -9,7 +9,7 @@ import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { ItemList } from '@/components/ui/lists/ItemList'; import { Switch } from '@/components/ui/forms/Switch'; import { DropdownMenu } from '@/components/ui/forms/dropdown/DropdownMenu'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { sync } from '@/sync/sync'; import { useAllMachines, useSettings } from '@/sync/domains/state/storage'; @@ -310,7 +310,7 @@ export default React.memo(function ProviderSettingsScreen() { itemTrigger={{ title: t('settingsSession.permissions.defaultPermissionModeTitle'), subtitle: getPermissionModeLabelForAgentType(providerId as any, permissionMode), - icon: <Ionicons name="shield-checkmark-outline" size={29} color="#34C759" />, + icon: <Ionicons name="shield-checkmark-outline" size={29} color={theme.colors.success} />, }} items={getPermissionModeOptionsForAgentType(providerId as any).map((opt) => ({ id: opt.value, diff --git a/apps/ui/sources/app/(app)/settings/providers/providerSettingsScreen.test.tsx b/apps/ui/sources/app/(app)/settings/providers/providerSettingsScreen.test.tsx index b0efafbf9..f5994c906 100644 --- a/apps/ui/sources/app/(app)/settings/providers/providerSettingsScreen.test.tsx +++ b/apps/ui/sources/app/(app)/settings/providers/providerSettingsScreen.test.tsx @@ -81,8 +81,9 @@ vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ }, })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/sync/sync', () => ({ diff --git a/apps/ui/sources/app/(app)/settings/session.actionsEntry.test.tsx b/apps/ui/sources/app/(app)/settings/session.actionsEntry.test.tsx index d5f877a39..efcca7a0f 100644 --- a/apps/ui/sources/app/(app)/settings/session.actionsEntry.test.tsx +++ b/apps/ui/sources/app/(app)/settings/session.actionsEntry.test.tsx @@ -19,20 +19,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - input: { placeholder: '#999', background: '#fff' }, - text: '#111', - textSecondary: '#666', - divider: '#e5e5e5', - }, - }, - }), - StyleSheet: { create: (v: any) => v, absoluteFillObject: {} }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: routerPushSpy }), })); @@ -57,8 +43,9 @@ vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ DropdownMenu: 'DropdownMenu', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/app/(app)/settings/session.permissionsEntry.test.tsx b/apps/ui/sources/app/(app)/settings/session.permissionsEntry.test.tsx index 9cb53f942..33e9351af 100644 --- a/apps/ui/sources/app/(app)/settings/session.permissionsEntry.test.tsx +++ b/apps/ui/sources/app/(app)/settings/session.permissionsEntry.test.tsx @@ -19,20 +19,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - input: { placeholder: '#999', background: '#fff' }, - text: '#111', - textSecondary: '#666', - divider: '#e5e5e5', - }, - }, - }), - StyleSheet: { create: (v: any) => v, absoluteFillObject: {} }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: routerPushSpy }), })); @@ -60,8 +46,9 @@ vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ : null, })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ @@ -127,4 +114,3 @@ describe('Session settings (Permissions entry)', () => { expect(routerPushSpy).toHaveBeenCalledWith('/(app)/settings/session/permissions'); }); }); - diff --git a/apps/ui/sources/app/(app)/settings/session.subAgentGate.test.tsx b/apps/ui/sources/app/(app)/settings/session.subAgentGate.test.tsx index da5e56efb..2281d2ba0 100644 --- a/apps/ui/sources/app/(app)/settings/session.subAgentGate.test.tsx +++ b/apps/ui/sources/app/(app)/settings/session.subAgentGate.test.tsx @@ -17,20 +17,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - input: { placeholder: '#999', background: '#fff' }, - text: '#111', - textSecondary: '#666', - divider: '#e5e5e5', - }, - }, - }), - StyleSheet: { create: (v: any) => v, absoluteFillObject: {} }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); @@ -55,8 +41,9 @@ vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ DropdownMenu: 'DropdownMenu', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ @@ -109,4 +96,3 @@ describe('Session settings (Sub-agent gate)', () => { expect(subAgentItem).toBeFalsy(); }); }); - diff --git a/apps/ui/sources/app/(app)/settings/session.thinkingDisplayMode.test.tsx b/apps/ui/sources/app/(app)/settings/session.thinkingDisplayMode.test.tsx index 74c5b225a..bc772c789 100644 --- a/apps/ui/sources/app/(app)/settings/session.thinkingDisplayMode.test.tsx +++ b/apps/ui/sources/app/(app)/settings/session.thinkingDisplayMode.test.tsx @@ -17,20 +17,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - input: { placeholder: '#999', background: '#fff' }, - text: '#111', - textSecondary: '#666', - divider: '#e5e5e5', - }, - }, - }), - StyleSheet: { create: (v: any) => v, absoluteFillObject: {} }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); @@ -68,8 +54,9 @@ vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ ), })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/app/(app)/settings/session.toolRenderingEntry.test.tsx b/apps/ui/sources/app/(app)/settings/session.toolRenderingEntry.test.tsx index 7849408bc..7c8cbc341 100644 --- a/apps/ui/sources/app/(app)/settings/session.toolRenderingEntry.test.tsx +++ b/apps/ui/sources/app/(app)/settings/session.toolRenderingEntry.test.tsx @@ -19,20 +19,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - input: { placeholder: '#999', background: '#fff' }, - text: '#111', - textSecondary: '#666', - divider: '#e5e5e5', - }, - }, - }), - StyleSheet: { create: (v: any) => v, absoluteFillObject: {} }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: routerPushSpy }), })); @@ -60,8 +46,9 @@ vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ : null, })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ @@ -135,4 +122,3 @@ describe('Session settings (Tool rendering entry)', () => { expect(routerPushSpy).toHaveBeenCalledWith('/(app)/settings/session/tool-rendering'); }); }); - diff --git a/apps/ui/sources/app/(app)/settings/session.tsx b/apps/ui/sources/app/(app)/settings/session.tsx index 3f7f9df97..9a4325e87 100644 --- a/apps/ui/sources/app/(app)/settings/session.tsx +++ b/apps/ui/sources/app/(app)/settings/session.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { View, TextInput, Platform } from 'react-native'; +import { View, Platform } from 'react-native'; import { useUnistyles, StyleSheet } from 'react-native-unistyles'; import { useRouter } from 'expo-router'; @@ -9,7 +9,7 @@ import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { ItemList } from '@/components/ui/lists/ItemList'; import { Switch } from '@/components/ui/forms/Switch'; import { DropdownMenu } from '@/components/ui/forms/dropdown/DropdownMenu'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { useSettingMutable } from '@/sync/domains/state/storage'; @@ -137,7 +137,7 @@ export default React.memo(function SessionSettingsScreen() { <Item title={t('settingsSession.sessionList.tagsTitle')} subtitle={sessionTagsEnabled ? t('settingsSession.sessionList.tagsEnabledSubtitle') : t('settingsSession.sessionList.tagsDisabledSubtitle')} - icon={<Ionicons name="pricetag-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="pricetag-outline" size={29} color={theme.colors.accent.blue} />} rightElement={<Switch value={Boolean(sessionTagsEnabled)} onValueChange={setSessionTagsEnabled} />} showChevron={false} onPress={() => setSessionTagsEnabled(!sessionTagsEnabled)} @@ -150,8 +150,8 @@ export default React.memo(function SessionSettingsScreen() { key={option.key} title={option.title} subtitle={option.subtitle} - icon={<Ionicons name="send-outline" size={29} color="#007AFF" />} - rightElement={messageSendMode === option.key ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + icon={<Ionicons name="send-outline" size={29} color={theme.colors.accent.blue} />} + rightElement={messageSendMode === option.key ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setMessageSendMode(option.key)} showChevron={false} /> @@ -168,8 +168,8 @@ export default React.memo(function SessionSettingsScreen() { key={option.key} title={option.title} subtitle={option.subtitle} - icon={<Ionicons name="git-branch-outline" size={29} color="#007AFF" />} - rightElement={busySteerSendPolicy === option.key ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + icon={<Ionicons name="git-branch-outline" size={29} color={theme.colors.accent.blue} />} + rightElement={busySteerSendPolicy === option.key ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setBusySteerSendPolicy(option.key)} showChevron={false} /> @@ -185,7 +185,7 @@ export default React.memo(function SessionSettingsScreen() { <Item title={t('settingsFeatures.enterToSend')} subtitle={agentInputEnterToSend ? t('settingsFeatures.enterToSendEnabled') : t('settingsFeatures.enterToSendDisabled')} - icon={<Ionicons name="return-down-forward-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="return-down-forward-outline" size={29} color={theme.colors.accent.blue} />} rightElement={<Switch value={agentInputEnterToSend} onValueChange={setAgentInputEnterToSend} />} showChevron={false} onPress={() => setAgentInputEnterToSend(!agentInputEnterToSend)} @@ -204,7 +204,7 @@ export default React.memo(function SessionSettingsScreen() { popoverBoundaryRef={popoverBoundaryRef} itemTrigger={{ title: t('settingsFeatures.historyScope'), - icon: <Ionicons name="time-outline" size={29} color="#007AFF" />, + icon: <Ionicons name="time-outline" size={29} color={theme.colors.accent.blue} />, }} items={historyScopeOptions.map((opt) => ({ id: opt.id, @@ -239,7 +239,7 @@ export default React.memo(function SessionSettingsScreen() { popoverBoundaryRef={popoverBoundaryRef} itemTrigger={{ title: t('settingsSession.thinking.displayModeTitle'), - icon: <Ionicons name="bulb-outline" size={29} color="#007AFF" />, + icon: <Ionicons name="bulb-outline" size={29} color={theme.colors.accent.blue} />, }} items={thinkingDisplayOptions.map((opt) => ({ id: opt.key, @@ -266,7 +266,7 @@ export default React.memo(function SessionSettingsScreen() { <Item title={t('settingsSession.toolRendering.title')} subtitle={t('settingsSession.toolDetailOverrides.title')} - icon={<Ionicons name="construct-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="construct-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push('/(app)/settings/session/tool-rendering')} /> </ItemGroup> @@ -275,7 +275,7 @@ export default React.memo(function SessionSettingsScreen() { <Item title={t('settingsSession.permissions.title')} subtitle={t('settingsSession.permissions.entrySubtitle')} - icon={<Ionicons name="shield-checkmark-outline" size={29} color="#34C759" />} + icon={<Ionicons name="shield-checkmark-outline" size={29} color={theme.colors.success} />} onPress={() => router.push('/(app)/settings/session/permissions')} /> </ItemGroup> @@ -287,7 +287,7 @@ export default React.memo(function SessionSettingsScreen() { <Item title={t('settingsSession.replayResume.enabledTitle')} subtitle={sessionReplayEnabled ? t('settingsSession.replayResume.enabledSubtitleOn') : t('settingsSession.replayResume.enabledSubtitleOff')} - icon={<Ionicons name="refresh-outline" size={29} color="#34C759" />} + icon={<Ionicons name="refresh-outline" size={29} color={theme.colors.success} />} rightElement={<Switch value={sessionReplayEnabled} onValueChange={setSessionReplayEnabled} />} showChevron={false} onPress={() => setSessionReplayEnabled(!sessionReplayEnabled)} @@ -308,7 +308,7 @@ export default React.memo(function SessionSettingsScreen() { popoverBoundaryRef={popoverBoundaryRef} itemTrigger={{ title: t('settingsSession.replayResume.strategyTitle'), - icon: <Ionicons name="list-outline" size={29} color="#34C759" />, + icon: <Ionicons name="list-outline" size={29} color={theme.colors.success} />, }} items={replayStrategyOptions.map((opt) => ({ id: opt.key, @@ -356,7 +356,7 @@ export default React.memo(function SessionSettingsScreen() { <Item title="Guidance rules" subtitle="Open Sub-agent settings" - icon={<Ionicons name="git-network-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="git-network-outline" size={29} color={theme.colors.accent.orange} />} onPress={() => router.push('/(app)/settings/sub-agent')} /> </ItemGroup> @@ -366,7 +366,7 @@ export default React.memo(function SessionSettingsScreen() { <Item title="Actions" subtitle="Open actions settings" - icon={<Ionicons name="flash-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="flash-outline" size={29} color={theme.colors.accent.orange} />} onPress={() => router.push('/(app)/settings/actions')} /> </ItemGroup> @@ -375,7 +375,7 @@ export default React.memo(function SessionSettingsScreen() { <Item title={t('profiles.tmux.spawnSessionsTitle')} subtitle={useTmux ? t('profiles.tmux.spawnSessionsEnabledSubtitle') : t('profiles.tmux.spawnSessionsDisabledSubtitle')} - icon={<Ionicons name="terminal-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="terminal-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={<Switch value={useTmux} onValueChange={setUseTmux} />} showChevron={false} onPress={() => setUseTmux(!useTmux)} @@ -399,7 +399,7 @@ export default React.memo(function SessionSettingsScreen() { <Item title={t('profiles.tmux.isolatedServerTitle')} subtitle={tmuxIsolated ? t('profiles.tmux.isolatedServerEnabledSubtitle') : t('profiles.tmux.isolatedServerDisabledSubtitle')} - icon={<Ionicons name="albums-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="albums-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={<Switch value={tmuxIsolated} onValueChange={setTmuxIsolated} />} showChevron={false} onPress={() => setTmuxIsolated(!tmuxIsolated)} @@ -433,7 +433,7 @@ export default React.memo(function SessionSettingsScreen() { ? t('settingsSession.terminalConnect.legacySecretExportEnabledSubtitle') : t('settingsSession.terminalConnect.legacySecretExportDisabledSubtitle') } - icon={<Ionicons name="shield-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="shield-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={ <Switch value={terminalConnectLegacySecretExportEnabled} diff --git a/apps/ui/sources/app/(app)/settings/session.webFeaturesMoved.test.tsx b/apps/ui/sources/app/(app)/settings/session.webFeaturesMoved.test.tsx index 0369d3f80..2465b729c 100644 --- a/apps/ui/sources/app/(app)/settings/session.webFeaturesMoved.test.tsx +++ b/apps/ui/sources/app/(app)/settings/session.webFeaturesMoved.test.tsx @@ -17,20 +17,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - input: { placeholder: '#999', background: '#fff' }, - text: '#111', - textSecondary: '#666', - divider: '#e5e5e5', - }, - }, - }), - StyleSheet: { create: (v: any) => v, absoluteFillObject: {} }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); @@ -60,8 +46,9 @@ vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ ), })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/app/(app)/settings/voice.deviceTtsTest.spec.tsx b/apps/ui/sources/app/(app)/settings/voice.deviceTtsTest.spec.tsx index f97114d8f..f7a00e477 100644 --- a/apps/ui/sources/app/(app)/settings/voice.deviceTtsTest.spec.tsx +++ b/apps/ui/sources/app/(app)/settings/voice.deviceTtsTest.spec.tsx @@ -7,17 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const speakDeviceTextSpy = vi.fn(); const modalAlertSpy = vi.fn(); -vi.mock('react-native-unistyles', () => { - const theme = { colors: { textSecondary: '#999' } }; - return { - useUnistyles: () => ({ theme }), - StyleSheet: { - create: (factory: any) => (typeof factory === 'function' ? {} : factory), - absoluteFillObject: {}, - }, - }; -}); - vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); diff --git a/apps/ui/sources/app/(app)/settings/voice.support.spec.tsx b/apps/ui/sources/app/(app)/settings/voice.support.spec.tsx index 0e140b8f8..338e2b973 100644 --- a/apps/ui/sources/app/(app)/settings/voice.support.spec.tsx +++ b/apps/ui/sources/app/(app)/settings/voice.support.spec.tsx @@ -9,17 +9,6 @@ const setVoice = vi.fn(); const decryptSecretValue = vi.fn<(value: unknown) => string | null>(() => null); const resetGlobalVoiceAgentPersistenceSpy = vi.fn(async () => {}); -vi.mock('react-native-unistyles', () => { - const theme = { colors: {} }; - return { - useUnistyles: () => ({ theme }), - StyleSheet: { - create: (factory: any) => (typeof factory === 'function' ? {} : factory), - absoluteFillObject: {}, - }, - }; -}); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); diff --git a/apps/ui/sources/app/(app)/share/[token].tsx b/apps/ui/sources/app/(app)/share/[token].tsx index fe0f1f465..af79dcf10 100644 --- a/apps/ui/sources/app/(app)/share/[token].tsx +++ b/apps/ui/sources/app/(app)/share/[token].tsx @@ -20,6 +20,7 @@ import { TranscriptList } from '@/components/sessions/transcript/TranscriptList' import { ChatHeaderView } from '@/components/sessions/transcript/ChatHeaderView'; import type { Message } from '@/sync/domains/messages/messageTypes'; import { serverFetch } from '@/sync/http/client'; +import { AgentStateSchema, MetadataSchema } from '@/sync/domains/state/storageTypes'; const SHARE_SCREEN_OPTIONS = { headerShown: false } as const; @@ -35,6 +36,7 @@ type PublicShareResponse = { session: { id: string; seq: number; + encryptionMode: 'e2ee' | 'plain'; createdAt: number; updatedAt: number; active: boolean; @@ -46,7 +48,7 @@ type PublicShareResponse = { }; owner: ShareOwner; accessLevel: 'view'; - encryptedDataKey: string; + encryptedDataKey: string | null; isConsentRequired: boolean; }; @@ -73,6 +75,7 @@ export default memo(function PublicShareViewerScreen() { const { credentials } = useAuth(); const router = useRouter(); const { theme } = useUnistyles(); + const tokenParam = typeof token === 'string' ? token : null; const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<string | null>(null); @@ -87,7 +90,7 @@ export default memo(function PublicShareViewerScreen() { }, [credentials?.token]); const load = useCallback(async (withConsent: boolean) => { - if (!token) { + if (!tokenParam) { setError(t('errors.invalidShareLink')); setIsLoading(false); return; @@ -102,8 +105,8 @@ export default memo(function PublicShareViewerScreen() { try { const path = withConsent - ? `/v1/public-share/${token}?consent=true` - : `/v1/public-share/${token}`; + ? `/v1/public-share/${tokenParam}?consent=true` + : `/v1/public-share/${tokenParam}`; const headers: Record<string, string> = {}; if (authHeader) { @@ -126,26 +129,6 @@ export default memo(function PublicShareViewerScreen() { } const data = (await response.json()) as PublicShareResponse; - const decryptedKey = await decryptDataKeyFromPublicShare(data.encryptedDataKey, token); - if (!decryptedKey) { - setError(t('session.sharing.failedToDecrypt')); - setIsLoading(false); - return; - } - - const sessionEncryptor = new AES256Encryption(decryptedKey); - const cache = new EncryptionCache(); - const sessionEncryption = new SessionEncryption(data.session.id, sessionEncryptor, cache); - - const decryptedMetadata = await sessionEncryption.decryptMetadata( - data.session.metadataVersion, - data.session.metadata - ); - - const decryptedAgentState = await sessionEncryption.decryptAgentState( - data.session.agentStateVersion, - data.session.agentState - ); const messagesPath = withConsent ? `/v1/public-share/${token}/messages?consent=true` @@ -157,15 +140,84 @@ export default memo(function PublicShareViewerScreen() { return; } const messagesData = (await messagesResponse.json()) as PublicShareMessagesResponse; - const decryptedMessages = await sessionEncryption.decryptMessages(messagesData.messages ?? []); const normalized: NormalizedMessage[] = []; - for (const m of decryptedMessages) { - if (!m || !m.content) continue; - const normalizedMessage = normalizeRawMessage(m.id, m.localId, m.createdAt, m.content); - if (normalizedMessage) { - normalized.push(normalizedMessage); + + const sessionEncryptionMode = data.session.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + const decryptedMetadata = (() => { + if (sessionEncryptionMode !== 'plain') return null; + try { + const raw = JSON.parse(data.session.metadata); + const parsed = MetadataSchema.safeParse(raw); + return parsed.success ? parsed.data : null; + } catch { + return null; + } + })(); + const decryptedAgentState = (() => { + if (sessionEncryptionMode !== 'plain') return {}; + if (!data.session.agentState) return {}; + try { + const raw = JSON.parse(data.session.agentState); + const parsed = AgentStateSchema.safeParse(raw); + return parsed.success ? parsed.data : {}; + } catch { + return {}; + } + })(); + + if (sessionEncryptionMode === 'plain') { + for (const m of messagesData.messages ?? []) { + if (!m) continue; + const content: any = (m as any).content; + if (!content || content.t !== 'plain') continue; + const normalizedMessage = normalizeRawMessage(m.id, m.localId ?? null, m.createdAt, content.v); + if (normalizedMessage) normalized.push(normalizedMessage); + } + } else { + if (!data.encryptedDataKey) { + setError(t('session.sharing.failedToDecrypt')); + setIsLoading(false); + return; } + + const decryptedKey = await decryptDataKeyFromPublicShare(data.encryptedDataKey, tokenParam); + if (!decryptedKey) { + setError(t('session.sharing.failedToDecrypt')); + setIsLoading(false); + return; + } + + const sessionEncryptor = new AES256Encryption(decryptedKey); + const cache = new EncryptionCache(); + const sessionEncryption = new SessionEncryption(data.session.id, sessionEncryptor, cache); + + const e2eeMetadata = await sessionEncryption.decryptMetadata( + data.session.metadataVersion, + data.session.metadata + ); + + const e2eeAgentState = await sessionEncryption.decryptAgentState( + data.session.agentStateVersion, + data.session.agentState + ); + + const decryptedMessages = await sessionEncryption.decryptMessages(messagesData.messages ?? []); + for (const m of decryptedMessages) { + if (!m || !m.content) continue; + const normalizedMessage = normalizeRawMessage(m.id, m.localId ?? null, m.createdAt, m.content); + if (normalizedMessage) normalized.push(normalizedMessage); + } + + const reducerState = createReducer(); + const reduced = reducer(reducerState, normalized, e2eeAgentState); + + setShare(data); + setDecryptedMetadata(e2eeMetadata); + setMessages(reduced.messages.slice(-200)); + setIsLoading(false); + return; } + normalized.sort((a, b) => a.createdAt - b.createdAt); const reducerState = createReducer(); @@ -179,7 +231,7 @@ export default memo(function PublicShareViewerScreen() { setError(t('errors.operationFailed')); setIsLoading(false); } - }, [authHeader, token]); + }, [authHeader, tokenParam]); useEffect(() => { void load(false); @@ -213,7 +265,7 @@ export default memo(function PublicShareViewerScreen() { <ItemGroup title={t('session.sharing.consentRequired')}> <Item title={t('session.sharing.sharedBy', { name: ownerName })} - icon={<Ionicons name="person-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="person-outline" size={29} color={theme.colors.accent.blue} />} showChevron={false} /> <Item @@ -224,12 +276,12 @@ export default memo(function PublicShareViewerScreen() { <ItemGroup> <Item title={t('session.sharing.acceptAndView')} - icon={<Ionicons name="checkmark-circle-outline" size={29} color="#34C759" />} + icon={<Ionicons name="checkmark-circle-outline" size={29} color={theme.colors.success} />} onPress={() => load(true)} /> <Item title={t('common.cancel')} - icon={<Ionicons name="close-circle-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="close-circle-outline" size={29} color={theme.colors.warningCritical} />} onPress={() => router.back()} /> </ItemGroup> diff --git a/apps/ui/sources/app/(app)/terminal/connect.hashParamsOrder.spec.tsx b/apps/ui/sources/app/(app)/terminal/connect.hashParamsOrder.spec.tsx index 750ffb651..e5956d60d 100644 --- a/apps/ui/sources/app/(app)/terminal/connect.hashParamsOrder.spec.tsx +++ b/apps/ui/sources/app/(app)/terminal/connect.hashParamsOrder.spec.tsx @@ -50,8 +50,9 @@ vi.mock('react-native', () => ({ }, })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/app/(app)/terminal/connect.tsx b/apps/ui/sources/app/(app)/terminal/connect.tsx index 3f6bd2754..79d4c2e9d 100644 --- a/apps/ui/sources/app/(app)/terminal/connect.tsx +++ b/apps/ui/sources/app/(app)/terminal/connect.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { View, Platform } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { useRouter } from 'expo-router'; import { Typography } from '@/constants/Typography'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; @@ -16,9 +16,11 @@ import { normalizeServerUrl, upsertActivateAndSwitchServer } from '@/sync/domain import { clearPendingTerminalConnect, setPendingTerminalConnect } from '@/sync/domains/pending/pendingTerminalConnect'; import { buildTerminalConnectDeepLink } from '@/utils/path/terminalConnectUrl'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { useUnistyles } from 'react-native-unistyles'; export default function TerminalConnectScreen() { const router = useRouter(); + const { theme } = useUnistyles(); const [publicKey, setPublicKey] = useState<string | null>(null); const [serverUrlFromHash, setServerUrlFromHash] = useState<string | null>(null); const [hashProcessed, setHashProcessed] = useState(false); @@ -105,7 +107,7 @@ export default function TerminalConnectScreen() { <Ionicons name="laptop-outline" size={64} - color="#8E8E93" + color={theme.colors.textSecondary} style={{ marginBottom: 16 }} /> <Text style={{ @@ -119,7 +121,7 @@ export default function TerminalConnectScreen() { <Text style={{ ...Typography.default(), fontSize: 14, - color: '#666', + color: theme.colors.textSecondary, textAlign: 'center', lineHeight: 20 }}> @@ -141,7 +143,7 @@ export default function TerminalConnectScreen() { paddingVertical: 32, paddingHorizontal: 16 }}> - <Text style={{ ...Typography.default(), color: '#666' }}> + <Text style={{ ...Typography.default(), color: theme.colors.textSecondary }}> {t('terminal.processingConnection')} </Text> </View> @@ -159,7 +161,7 @@ export default function TerminalConnectScreen() { paddingVertical: 32, paddingHorizontal: 16 }}> - <Text style={{ ...Typography.default(), color: '#666', textAlign: 'center', lineHeight: 20 }}> + <Text style={{ ...Typography.default(), color: theme.colors.textSecondary, textAlign: 'center', lineHeight: 20 }}> {t('modals.pleaseSignInFirst')} </Text> </View> @@ -181,13 +183,13 @@ export default function TerminalConnectScreen() { <Ionicons name="warning-outline" size={48} - color="#FF3B30" + color={theme.colors.warningCritical} style={{ marginBottom: 16 }} /> <Text style={{ ...Typography.default('semiBold'), fontSize: 16, - color: '#FF3B30', + color: theme.colors.textDestructive, textAlign: 'center', marginBottom: 8 }}> @@ -196,7 +198,7 @@ export default function TerminalConnectScreen() { <Text style={{ ...Typography.default(), fontSize: 14, - color: '#666', + color: theme.colors.textSecondary, textAlign: 'center', lineHeight: 20 }}> @@ -221,7 +223,7 @@ export default function TerminalConnectScreen() { <Ionicons name="terminal-outline" size={48} - color="#007AFF" + color={theme.colors.accent.blue} style={{ marginBottom: 16 }} /> <Text style={{ @@ -235,7 +237,7 @@ export default function TerminalConnectScreen() { <Text style={{ ...Typography.default(), fontSize: 14, - color: '#666', + color: theme.colors.textSecondary, textAlign: 'center', lineHeight: 20 }}> @@ -249,13 +251,13 @@ export default function TerminalConnectScreen() { <Item title={t('terminal.publicKey')} detail={`${publicKey.substring(0, 12)}...`} - icon={<Ionicons name="key-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="key-outline" size={29} color={theme.colors.accent.blue} />} showChevron={false} /> <Item title={t('terminal.encryption')} detail={t('terminal.endToEndEncrypted')} - icon={<Ionicons name="lock-closed-outline" size={29} color="#34C759" />} + icon={<Ionicons name="lock-closed-outline" size={29} color={theme.colors.success} />} showChevron={false} /> </ItemGroup> @@ -268,6 +270,7 @@ export default function TerminalConnectScreen() { gap: 12 }}> <RoundButton + testID="terminal-connect-approve" title={isLoading ? t('terminal.connecting') : t('terminal.acceptConnection')} onPress={handleConnect} size="large" @@ -275,6 +278,7 @@ export default function TerminalConnectScreen() { loading={isLoading} /> <RoundButton + testID="terminal-connect-reject" title={t('terminal.reject')} onPress={handleReject} size="large" @@ -292,7 +296,7 @@ export default function TerminalConnectScreen() { <Item title={t('terminal.clientSideProcessing')} subtitle={t('terminal.linkProcessedLocally')} - icon={<Ionicons name="shield-checkmark-outline" size={29} color="#34C759" />} + icon={<Ionicons name="shield-checkmark-outline" size={29} color={theme.colors.success} />} showChevron={false} /> </ItemGroup> diff --git a/apps/ui/sources/app/(app)/terminal/connect.unauthRedirect.spec.tsx b/apps/ui/sources/app/(app)/terminal/connect.unauthRedirect.spec.tsx index 30b652b00..d13496b6c 100644 --- a/apps/ui/sources/app/(app)/terminal/connect.unauthRedirect.spec.tsx +++ b/apps/ui/sources/app/(app)/terminal/connect.unauthRedirect.spec.tsx @@ -44,8 +44,9 @@ vi.mock('react-native', () => ({ Platform: { OS: 'web' }, })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/app/(app)/terminal/index.authButtons.spec.tsx b/apps/ui/sources/app/(app)/terminal/index.authButtons.spec.tsx new file mode 100644 index 000000000..ad2364c59 --- /dev/null +++ b/apps/ui/sources/app/(app)/terminal/index.authButtons.spec.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const clearPendingMock = vi.fn(); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), + useLocalSearchParams: () => ({ key: 'abc123', server: 'https://example.test' }), +})); + +vi.mock('@/hooks/session/useConnectTerminal', () => ({ + useConnectTerminal: () => ({ processAuthUrl: vi.fn(async () => {}), isLoading: false }), +})); + +vi.mock('@/auth/context/AuthContext', () => ({ + useAuth: () => ({ isAuthenticated: true, credentials: { token: 't', secret: 's' } }), +})); + +vi.mock('@/sync/domains/pending/pendingTerminalConnect', () => ({ + setPendingTerminalConnect: vi.fn(), + clearPendingTerminalConnect: (...args: any[]) => clearPendingMock(...args), + getPendingTerminalConnect: () => null, +})); + +vi.mock('react-native', () => ({ + View: 'View', +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { colors: { textDestructive: '#f00', textSecondary: '#666', radio: { active: '#0af' }, text: '#000', success: '#0a0' } }, + }), +})); + +vi.mock('@/components/ui/text/Text', () => ({ + Text: 'Text', + TextInput: 'TextInput', +})); + +vi.mock('@/constants/Typography', () => ({ + Typography: { default: () => ({}) }, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/components/ui/buttons/RoundButton', () => ({ + RoundButton: (props: any) => React.createElement('RoundButton', props, null), +})); + +vi.mock('@/components/ui/lists/ItemList', () => ({ + ItemList: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/ui/lists/ItemGroup', () => ({ + ItemGroup: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/ui/lists/Item', () => ({ + Item: () => null, +})); + +vi.mock('@/sync/domains/server/serverConfig', () => ({ + getServerUrl: () => 'https://api.happier.dev', +})); + +describe('TerminalScreen authenticated buttons', () => { + beforeEach(() => { + vi.resetModules(); + clearPendingMock.mockClear(); + }); + + it('exposes stable testIDs for approve/reject buttons on /terminal', async () => { + const Screen = (await import('./index')).default; + + let tree: renderer.ReactTestRenderer | undefined; + try { + await act(async () => { + tree = renderer.create(<Screen />); + }); + await act(async () => {}); + + const buttonTestIds = tree!.root + .findAll((node) => (node.type as any) === 'RoundButton') + .map((node) => node.props?.testID) + .filter(Boolean); + + expect(buttonTestIds).toContain('terminal-connect-approve'); + expect(buttonTestIds).toContain('terminal-connect-reject'); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); +}); + diff --git a/apps/ui/sources/app/(app)/terminal/index.legacyFallback.spec.tsx b/apps/ui/sources/app/(app)/terminal/index.legacyFallback.spec.tsx index 0f865a258..c72f0390c 100644 --- a/apps/ui/sources/app/(app)/terminal/index.legacyFallback.spec.tsx +++ b/apps/ui/sources/app/(app)/terminal/index.legacyFallback.spec.tsx @@ -41,8 +41,9 @@ vi.mock('react-native', () => ({ View: 'View', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/app/(app)/terminal/index.tsx b/apps/ui/sources/app/(app)/terminal/index.tsx index c20e83d1f..aef0ff2c1 100644 --- a/apps/ui/sources/app/(app)/terminal/index.tsx +++ b/apps/ui/sources/app/(app)/terminal/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { Typography } from '@/constants/Typography'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; @@ -207,6 +207,7 @@ export default function TerminalScreen() { gap: 12 }}> <RoundButton + testID="terminal-connect-approve" title={isLoading ? t('terminal.connecting') : t('terminal.acceptConnection')} onPress={handleConnect} size="large" @@ -214,6 +215,7 @@ export default function TerminalScreen() { loading={isLoading} /> <RoundButton + testID="terminal-connect-reject" title={t('terminal.reject')} onPress={handleReject} size="large" diff --git a/apps/ui/sources/app/(app)/terminal/index.unauthRedirect.spec.tsx b/apps/ui/sources/app/(app)/terminal/index.unauthRedirect.spec.tsx index 8ea02ca7c..173804a2a 100644 --- a/apps/ui/sources/app/(app)/terminal/index.unauthRedirect.spec.tsx +++ b/apps/ui/sources/app/(app)/terminal/index.unauthRedirect.spec.tsx @@ -38,8 +38,9 @@ vi.mock('react-native', () => ({ View: 'View', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/app/(app)/text-selection.tsx b/apps/ui/sources/app/(app)/text-selection.tsx index 804a9cda7..5da2b1675 100644 --- a/apps/ui/sources/app/(app)/text-selection.tsx +++ b/apps/ui/sources/app/(app)/text-selection.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, ScrollView, TextInput, Pressable } from 'react-native'; +import { View, ScrollView, Pressable } from 'react-native'; import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -9,6 +9,8 @@ import { t } from '@/text'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; import { Ionicons } from '@expo/vector-icons'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export default function TextSelectionScreen() { const router = useRouter(); diff --git a/apps/ui/sources/app/(app)/user/[id].tsx b/apps/ui/sources/app/(app)/user/[id].tsx index d2818681d..891535742 100644 --- a/apps/ui/sources/app/(app)/user/[id].tsx +++ b/apps/ui/sources/app/(app)/user/[id].tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { View, ActivityIndicator, Linking } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { useAuth } from '@/auth/context/AuthContext'; import { getUserProfile, sendFriendRequest, removeFriend } from '@/sync/api/social/apiFriends'; import { UserProfile, getDisplayName } from '@/sync/domains/social/friendTypes'; @@ -119,7 +119,7 @@ export default function UserProfileScreen() { if (isLoading) { return ( <View style={styles.loadingContainer}> - <ActivityIndicator size="large" color="#007AFF" /> + <ActivityIndicator size="large" color={theme.colors.accent.blue} /> </View> ); } @@ -141,7 +141,7 @@ export default function UserProfileScreen() { case 'friend': return [{ title: t('friends.removeFriend'), - icon: <Ionicons name="person-remove-outline" size={29} color="#FF3B30" />, + icon: <Ionicons name="person-remove-outline" size={29} color={theme.colors.warningCritical} />, onPress: handleRemoveFriend, loading: removingFriend, }]; @@ -150,13 +150,13 @@ export default function UserProfileScreen() { return [ { title: t('friends.acceptRequest'), - icon: <Ionicons name="checkmark-circle-outline" size={29} color="#34C759" />, + icon: <Ionicons name="checkmark-circle-outline" size={29} color={theme.colors.success} />, onPress: addFriend, loading: addingFriend, }, { title: t('friends.denyRequest'), - icon: <Ionicons name="close-circle-outline" size={29} color="#FF3B30" />, + icon: <Ionicons name="close-circle-outline" size={29} color={theme.colors.warningCritical} />, onPress: handleRemoveFriend, loading: removingFriend, } @@ -165,7 +165,7 @@ export default function UserProfileScreen() { // User has sent a friend request return [{ title: t('friends.cancelRequest'), - icon: <Ionicons name="close-outline" size={29} color="#FF9500" />, + icon: <Ionicons name="close-outline" size={29} color={theme.colors.accent.orange} />, onPress: handleRemoveFriend, loading: removingFriend, }]; @@ -174,7 +174,7 @@ export default function UserProfileScreen() { default: return [{ title: t('friends.requestFriendship'), - icon: <Ionicons name="person-add-outline" size={29} color="#007AFF" />, + icon: <Ionicons name="person-add-outline" size={29} color={theme.colors.accent.blue} />, onPress: addFriend, loading: addingFriend, }]; @@ -212,7 +212,7 @@ export default function UserProfileScreen() { {/* Friend Status Badge */} {userProfile.status === 'friend' && ( <View style={styles.statusBadge}> - <Ionicons name="checkmark-circle" size={16} color="#34C759" /> + <Ionicons name="checkmark-circle" size={16} color={theme.colors.success} /> <Text style={styles.statusText}>{t('friends.alreadyFriends')}</Text> </View> )} @@ -242,7 +242,7 @@ export default function UserProfileScreen() { key={session.id} title={session.metadata?.name || session.metadata?.path || t('sessionHistory.title')} subtitle={t('session.sharing.viewOnly')} - icon={<Ionicons name="chatbubble-ellipses-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="chatbubble-ellipses-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push(`/session/${session.id}`)} /> )) @@ -378,7 +378,7 @@ const styles = StyleSheet.create((theme) => ({ }, statusText: { fontSize: 13, - color: '#34C759', + color: theme.colors.success, marginLeft: 4, fontWeight: '500', }, diff --git a/apps/ui/sources/app/_layout.init.spec.tsx b/apps/ui/sources/app/_layout.init.spec.tsx index 26dc52877..a7e9d280b 100644 --- a/apps/ui/sources/app/_layout.init.spec.tsx +++ b/apps/ui/sources/app/_layout.init.spec.tsx @@ -229,9 +229,9 @@ describe('app/_layout init resilience', () => { mockedPlatformOS = 'web'; fromModuleMock.mockImplementation(() => ({ uri: 'https://example.com/font.ttf' })); - let appended: any | null = null; + const appended: any[] = []; const appendChild = vi.fn((node: any) => { - appended = node; + appended.push(node); }); Object.defineProperty(globalThis, 'document', { @@ -256,11 +256,12 @@ describe('app/_layout init resilience', () => { }); expect(loadAsyncMock).toHaveBeenCalledTimes(0); - expect(appendChild).toHaveBeenCalledTimes(1); - expect(typeof appended?.textContent).toBe('string'); - expect(appended.textContent).toContain('@font-face'); - expect(appended.textContent).toContain('Inter-Regular'); - expect(appended.textContent).toContain('example.com/font.ttf'); + // We inject a <style> for @font-face rules and also add a <style> for UI font scaling overrides. + expect(appendChild).toHaveBeenCalledTimes(2); + const texts = appended.map((n) => String(n?.textContent ?? '')); + expect(texts.some((t) => t.includes('@font-face'))).toBe(true); + expect(texts.some((t) => t.includes('Inter-Regular'))).toBe(true); + expect(texts.some((t) => t.includes('example.com/font.ttf'))).toBe(true); expect(tree!.toJSON()).not.toBeNull(); }); diff --git a/apps/ui/sources/app/_layout.tsx b/apps/ui/sources/app/_layout.tsx index dbe142ab2..195874e72 100644 --- a/apps/ui/sources/app/_layout.tsx +++ b/apps/ui/sources/app/_layout.tsx @@ -19,6 +19,9 @@ import { ModalProvider } from '@/modal'; import { PostHogProvider } from 'posthog-react-native'; import { tracking } from '@/track/tracking'; import { syncRestore } from '@/sync/sync'; +import { storage } from '@/sync/domains/state/storage'; +import { getActiveViewingSessionId } from '@/sync/domains/session/activeViewingSession'; +import { NotificationsSettingsV1Schema } from '@happier-dev/protocol'; import { useTrackScreens } from '@/track/useTrackScreens'; import { RealtimeProvider } from '@/realtime/RealtimeProvider'; import { FaviconPermissionIndicator } from '@/components/web/FaviconPermissionIndicator'; @@ -31,6 +34,7 @@ import { installBugReportConsoleCapture } from '@/utils/system/bugReportLogBuffe import { configureBugReportUserActionTrail } from '@/utils/system/bugReportActionTrail'; import { useUnistyles } from 'react-native-unistyles'; import { AsyncLock } from '@/utils/system/lock'; +import { useWebUiFontScale } from '@/components/ui/text/useWebUiFontScale'; function shouldCaptureRnwUnexpectedTextNodeStacks(): boolean { // Dev-only diagnostics: enable via `?debugRnwTextNode=1` on web. @@ -262,15 +266,33 @@ function installReactJsxRuntimeUnexpectedTextNodeCaptureOnce() { } } -// Configure notification handler for foreground notifications +// Configure notification handler for foreground notifications. +// Suppresses same-session notifications and respects the foregroundBehavior setting. Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: true, - shouldShowBanner: true, - shouldShowList: true, - }), + handleNotification: async (notification) => { + const { data } = notification.request.content; + const notifSessionId = typeof data?.sessionId === 'string' ? data.sessionId : null; + + // Same-session suppression: user already sees real-time updates. + if (notifSessionId && notifSessionId === getActiveViewingSessionId()) { + return { shouldPlaySound: false, shouldSetBadge: true, shouldShowBanner: false, shouldShowList: false }; + } + + // NotificationsSettingsV1Schema uses .catch(), so parse always succeeds. + const { foregroundBehavior } = NotificationsSettingsV1Schema.parse( + storage.getState().settings.notificationsSettingsV1, + ); + + switch (foregroundBehavior) { + case 'off': + return { shouldPlaySound: false, shouldSetBadge: true, shouldShowBanner: false, shouldShowList: false }; + case 'silent': + return { shouldPlaySound: false, shouldSetBadge: true, shouldShowBanner: true, shouldShowList: true }; + case 'full': + default: + return { shouldPlaySound: true, shouldSetBadge: true, shouldShowBanner: true, shouldShowList: true }; + } + }, }); // Setup Android notification channel (required for Android 8.0+) @@ -517,6 +539,7 @@ async function loadFonts() { export default function RootLayout() { const { theme } = useUnistyles(); + useWebUiFontScale(); const navigationTheme = React.useMemo(() => { if (theme.dark) { return { diff --git a/apps/ui/sources/auth/context/AuthContext.login.test.tsx b/apps/ui/sources/auth/context/AuthContext.login.test.tsx new file mode 100644 index 000000000..485feb663 --- /dev/null +++ b/apps/ui/sources/auth/context/AuthContext.login.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } +).IS_REACT_ACT_ENVIRONMENT = true; + +const secureStore = vi.hoisted(() => new Map<string, string>()); +vi.mock('expo-secure-store', () => ({ + getItemAsync: async (key: string) => secureStore.get(key) ?? null, + setItemAsync: async (key: string, value: string) => { + secureStore.set(key, value); + }, + deleteItemAsync: async (key: string) => { + secureStore.delete(key); + }, +})); + +vi.mock('@/log', () => ({ + log: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@/voice/context/voiceHooks', () => ({ + voiceHooks: { + onSessionFocus: vi.fn(), + onSessionOffline: vi.fn(), + onSessionOnline: vi.fn(), + onMessages: vi.fn(), + reportContextualUpdate: vi.fn(), + }, +})); + +vi.mock('@/track', () => ({ + trackLogout: vi.fn(), + initializeTracking: vi.fn(), + tracking: null, +})); + +function buildTokenWithSub(sub: string): string { + const payload = Buffer.from(JSON.stringify({ sub })).toString('base64'); + return `hdr.${payload}.sig`; +} + +describe('AuthContext.login', () => { + beforeEach(() => { + vi.useFakeTimers(); + secureStore.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('resolves without waiting for syncSwitchServer to finish', async () => { + // Make sync's initial HTTP work hang so `syncSwitchServer` cannot complete until timers advance. + vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(() => {}))); + + const { upsertAndActivateServer } = await import('@/sync/domains/server/serverRuntime'); + upsertAndActivateServer({ serverUrl: 'http://localhost:53288', scope: 'tab' }); + + const { AuthProvider, getCurrentAuth } = await import('./AuthContext'); + + let tree: renderer.ReactTestRenderer | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(AuthProvider, { + initialCredentials: null, + children: React.createElement(React.Fragment, null), + }), + ); + }); + + try { + // Flush effects that wire `getCurrentAuth()`. + await act(async () => {}); + const auth = getCurrentAuth(); + if (!auth) throw new Error('Expected current auth to be set'); + + let resolved = false; + let promise: Promise<void> | null = null; + await act(async () => { + promise = auth.login(buildTokenWithSub('server-test'), 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA').then(() => { + resolved = true; + }); + // Allow async crypto/storage work to settle, without advancing long-running sync timers. + await vi.advanceTimersByTimeAsync(1); + }); + expect(resolved).toBe(true); + await promise; + } finally { + if (tree) { + act(() => { + tree!.unmount(); + }); + } + } + }); +}); diff --git a/apps/ui/sources/auth/context/AuthContext.tsx b/apps/ui/sources/auth/context/AuthContext.tsx index c39762f57..374d1fb71 100644 --- a/apps/ui/sources/auth/context/AuthContext.tsx +++ b/apps/ui/sources/auth/context/AuthContext.tsx @@ -12,6 +12,7 @@ interface AuthContextType { isAuthenticated: boolean; credentials: AuthCredentials | null; login: (token: string, secret: string) => Promise<void>; + loginWithCredentials: (credentials: AuthCredentials) => Promise<void>; logout: () => Promise<void>; refreshFromActiveServer: () => Promise<void>; } @@ -23,17 +24,24 @@ export function AuthProvider({ children, initialCredentials }: { children: React const [credentials, setCredentials] = useState<AuthCredentials | null>(initialCredentials); const activeServerKeyRef = React.useRef<string | null>(null); - const login = React.useCallback(async (token: string, secret: string) => { - const newCredentials: AuthCredentials = { token, secret }; + const loginWithCredentials = React.useCallback(async (newCredentials: AuthCredentials) => { const success = await TokenStorage.setCredentials(newCredentials); if (!success) { throw new Error('Failed to save credentials'); } - await syncSwitchServer(newCredentials); setCredentials(newCredentials); setIsAuthenticated(true); + fireAndForget(syncSwitchServer(newCredentials), { tag: 'AuthContext.login.syncSwitchServer' }); }, []); + const login = React.useCallback( + async (token: string, secret: string) => { + const newCredentials: AuthCredentials = { token, secret }; + await loginWithCredentials(newCredentials); + }, + [loginWithCredentials], + ); + const logout = React.useCallback(async () => { trackLogout(); clearPersistence(); @@ -55,10 +63,11 @@ export function AuthProvider({ children, initialCredentials }: { children: React isAuthenticated, credentials, login, + loginWithCredentials, logout, refreshFromActiveServer, }); - }, [isAuthenticated, credentials, login, logout, refreshFromActiveServer]); + }, [isAuthenticated, credentials, login, loginWithCredentials, logout, refreshFromActiveServer]); useEffect(() => { const unsubscribe = subscribeActiveServer((snapshot) => { @@ -87,6 +96,7 @@ export function AuthProvider({ children, initialCredentials }: { children: React isAuthenticated, credentials, login, + loginWithCredentials, logout, refreshFromActiveServer, }} diff --git a/apps/ui/sources/auth/flows/buildDataKeyCredentialsForToken.test.ts b/apps/ui/sources/auth/flows/buildDataKeyCredentialsForToken.test.ts new file mode 100644 index 000000000..68ea5d73c --- /dev/null +++ b/apps/ui/sources/auth/flows/buildDataKeyCredentialsForToken.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { installLocalStorageMock } from '@/auth/storage/tokenStorage.web.testHelpers'; + +vi.mock('react-native', () => ({ + Platform: { OS: 'web' }, +})); + +vi.mock('expo-secure-store', () => ({})); + +const getRandomBytesAsyncMock = vi.fn(async (len: number) => new Uint8Array(len).fill(7)); +vi.mock('@/platform/cryptoRandom', () => ({ + getRandomBytesAsync: (len: number) => getRandomBytesAsyncMock(len), +})); + +describe('buildDataKeyCredentialsForToken', () => { + let restoreLocalStorage: (() => void) | null = null; + + beforeEach(() => { + vi.resetModules(); + getRandomBytesAsyncMock.mockClear(); + }); + + afterEach(async () => { + restoreLocalStorage?.(); + restoreLocalStorage = null; + vi.restoreAllMocks(); + try { + const { setServerUrl } = await import('@/sync/domains/server/serverConfig'); + setServerUrl(null); + } catch { + // ignore + } + }); + + it('creates new dataKey credentials when no stored dataKey credentials exist', async () => { + restoreLocalStorage = installLocalStorageMock().restore; + + const { setServerUrl } = await import('@/sync/domains/server/serverConfig'); + setServerUrl('https://server.example.test'); + + const { buildDataKeyCredentialsForToken } = await import('./buildDataKeyCredentialsForToken'); + const creds = await buildDataKeyCredentialsForToken('token-1'); + + expect(creds).toHaveProperty('token', 'token-1'); + expect((creds as any).encryption?.publicKey).toEqual(expect.any(String)); + expect((creds as any).encryption?.machineKey).toEqual(expect.any(String)); + }); + + it('reuses stored dataKey encryption keys when present', async () => { + restoreLocalStorage = installLocalStorageMock().restore; + + const { setServerUrl } = await import('@/sync/domains/server/serverConfig'); + setServerUrl('https://server.example.test'); + + const { TokenStorage } = await import('@/auth/storage/tokenStorage'); + await expect( + TokenStorage.setCredentials({ + token: 'old-token', + encryption: { publicKey: 'pk', machineKey: 'mk' }, + } as any), + ).resolves.toBe(true); + + const { buildDataKeyCredentialsForToken } = await import('./buildDataKeyCredentialsForToken'); + const creds = await buildDataKeyCredentialsForToken('new-token'); + + expect(creds).toEqual({ token: 'new-token', encryption: { publicKey: 'pk', machineKey: 'mk' } }); + }); +}); + diff --git a/apps/ui/sources/auth/flows/buildDataKeyCredentialsForToken.ts b/apps/ui/sources/auth/flows/buildDataKeyCredentialsForToken.ts new file mode 100644 index 000000000..e9a6efe77 --- /dev/null +++ b/apps/ui/sources/auth/flows/buildDataKeyCredentialsForToken.ts @@ -0,0 +1,24 @@ +import sodium from '@/encryption/libsodium.lib'; +import { encodeBase64 } from '@/encryption/base64'; +import { getRandomBytesAsync } from '@/platform/cryptoRandom'; +import { TokenStorage, isLegacyAuthCredentials, type AuthCredentials } from '@/auth/storage/tokenStorage'; + +export async function buildDataKeyCredentialsForToken(token: string): Promise<AuthCredentials> { + const normalizedToken = token.toString(); + + const existing = await TokenStorage.getCredentials(); + if (existing && !isLegacyAuthCredentials(existing)) { + return { token: normalizedToken, encryption: existing.encryption }; + } + + const seed = await getRandomBytesAsync(32); + const keyPair = sodium.crypto_box_seed_keypair(seed); + return { + token: normalizedToken, + encryption: { + publicKey: encodeBase64(keyPair.publicKey), + machineKey: encodeBase64(keyPair.privateKey), + }, + }; +} + diff --git a/apps/ui/sources/auth/flows/getToken.keyChallengeGate.test.ts b/apps/ui/sources/auth/flows/getToken.keyChallengeGate.test.ts new file mode 100644 index 000000000..f5bf5859d --- /dev/null +++ b/apps/ui/sources/auth/flows/getToken.keyChallengeGate.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + return { + serverFetch: vi.fn(), + }; +}); + +vi.mock('@/sync/http/client', () => ({ + serverFetch: mocks.serverFetch, +})); + +import { authGetToken } from './getToken'; +import { resetServerFeaturesClientForTests } from '@/sync/api/capabilities/serverFeaturesClient'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('authGetToken key-challenge gate', () => { + beforeEach(() => { + resetServerFeaturesClientForTests(); + mocks.serverFetch.mockReset(); + }); + + it('fails fast when server disables key-challenge login', async () => { + mocks.serverFetch.mockResolvedValueOnce( + jsonResponse({ + features: { auth: { login: { keyChallenge: { enabled: false } } } }, + capabilities: {}, + }), + ); + + await expect(authGetToken(new Uint8Array(32))).rejects.toThrow(/key-challenge/i); + expect(mocks.serverFetch).toHaveBeenCalledTimes(1); + expect(mocks.serverFetch.mock.calls[0]?.[0]).toBe('/v1/features'); + }); + + it('does not fail fast when server does not advertise key-challenge gate (legacy server)', async () => { + mocks.serverFetch + .mockResolvedValueOnce( + jsonResponse({ + features: { + auth: { recovery: { providerReset: { enabled: false } }, ui: { recoveryKeyReminder: { enabled: true } } }, + sharing: { contentKeys: { enabled: false } }, + }, + capabilities: {}, + }), + ) + .mockResolvedValueOnce(jsonResponse({ token: 'legacy-token' })); + + await expect(authGetToken(new Uint8Array(32))).resolves.toBe('legacy-token'); + expect(mocks.serverFetch).toHaveBeenCalledTimes(2); + expect(mocks.serverFetch.mock.calls[0]?.[0]).toBe('/v1/features'); + expect(mocks.serverFetch.mock.calls[1]?.[0]).toBe('/v1/auth'); + }); + + it('continues when server enables key-challenge login', async () => { + mocks.serverFetch + .mockResolvedValueOnce( + jsonResponse({ + features: { + auth: { login: { keyChallenge: { enabled: true } } }, + sharing: { contentKeys: { enabled: false } }, + }, + capabilities: {}, + }), + ) + .mockResolvedValueOnce(jsonResponse({ token: 'test-token' })); + + await expect(authGetToken(new Uint8Array(32))).resolves.toBe('test-token'); + expect(mocks.serverFetch).toHaveBeenCalledTimes(2); + expect(mocks.serverFetch.mock.calls[0]?.[0]).toBe('/v1/features'); + expect(mocks.serverFetch.mock.calls[1]?.[0]).toBe('/v1/auth'); + }); +}); diff --git a/apps/ui/sources/auth/flows/getToken.ts b/apps/ui/sources/auth/flows/getToken.ts index 57f058521..9be3b3829 100644 --- a/apps/ui/sources/auth/flows/getToken.ts +++ b/apps/ui/sources/auth/flows/getToken.ts @@ -2,12 +2,25 @@ import { authChallenge } from "./challenge"; import { encodeBase64 } from "@/encryption/base64"; import { Encryption } from "@/sync/encryption/encryption"; import sodium from '@/encryption/libsodium.lib'; -import { isSessionSharingSupported } from '@/sync/api/capabilities/sessionSharingSupport'; +import { getReadyServerFeatures } from '@/sync/api/capabilities/getReadyServerFeatures'; import { serverFetch } from '@/sync/http/client'; +import { readServerEnabledBit } from '@happier-dev/protocol'; const CONTENT_KEY_BINDING_PREFIX = new TextEncoder().encode('Happy content key v1\u0000'); export async function authGetToken(secret: Uint8Array) { + const serverFeatures = await getReadyServerFeatures({ timeoutMs: 800 }); + if (serverFeatures) { + // Backward compatibility: + // - New servers explicitly advertise `features.auth.login.keyChallenge.enabled`. + // - Older servers don't advertise it at all. In that case we must NOT fail fast, + // because key-challenge login may still be supported (the server just predates this gate). + const keyChallengeEnabledRaw = (serverFeatures as any)?.features?.auth?.login?.keyChallenge?.enabled; + if (typeof keyChallengeEnabledRaw === 'boolean' && keyChallengeEnabledRaw === false) { + throw new Error('Authentication failed: key-challenge login is disabled on this server.'); + } + } + const { challenge, signature, publicKey } = authChallenge(secret); const body: any = { @@ -18,8 +31,9 @@ export async function authGetToken(secret: Uint8Array) { // Backward compatibility: only send new key fields when the server advertises support. // Older servers validate request bodies strictly and would reject unknown fields. - const supportsSharing = await isSessionSharingSupported({ timeoutMs: 800 }); - if (supportsSharing) { + const supportsContentKeys = + serverFeatures ? readServerEnabledBit(serverFeatures, 'sharing.contentKeys') === true : false; + if (supportsContentKeys) { const encryption = await Encryption.create(secret); const contentPublicKey = encryption.contentDataKey; diff --git a/apps/ui/sources/auth/providers/externalOAuthProvider.test.ts b/apps/ui/sources/auth/providers/externalOAuthProvider.test.ts index c840b827e..3ab07de3d 100644 --- a/apps/ui/sources/auth/providers/externalOAuthProvider.test.ts +++ b/apps/ui/sources/auth/providers/externalOAuthProvider.test.ts @@ -62,6 +62,27 @@ describe('createExternalOAuthProvider', () => { ); }); + it('returns the external keyless login URL when params endpoint is successful', async () => { + let capturedUrl: string | null = null; + stubFetch(async (url) => { + capturedUrl = url; + return { + ok: true, + status: 200, + body: { url: 'https://oauth.example.test/login' }, + }; + }); + + const provider = createProvider(); + expect(provider.getExternalLoginUrl).toBeDefined(); + await expect(provider.getExternalLoginUrl!({ proofHash: 'abc123' })).resolves.toBe( + 'https://oauth.example.test/login', + ); + expect(capturedUrl).toContain('/v1/auth/external/github/params'); + expect(capturedUrl).toContain('mode=keyless'); + expect(capturedUrl).toContain('proofHash=abc123'); + }); + it('throws config HappyError when external OAuth is not configured', async () => { stubFetch(async () => ({ ok: false, diff --git a/apps/ui/sources/auth/providers/externalOAuthProvider.ts b/apps/ui/sources/auth/providers/externalOAuthProvider.ts index ac93e6289..47efbb79b 100644 --- a/apps/ui/sources/auth/providers/externalOAuthProvider.ts +++ b/apps/ui/sources/auth/providers/externalOAuthProvider.ts @@ -2,6 +2,7 @@ import type { AuthCredentials } from '@/auth/storage/tokenStorage'; import { HappyError } from '@/utils/errors/errors'; import { backoff } from '@/utils/timing/time'; import { serverFetch } from '@/sync/http/client'; +import { t } from '@/text'; import type { AuthProvider } from '@/auth/providers/types'; import type { AuthProviderId } from '@happier-dev/protocol'; @@ -14,6 +15,7 @@ export function createExternalOAuthProvider(params: { badgeIconName?: string; supportsProfileBadge?: boolean; connectButtonColor?: string; + getRestoreRedirectNotice?: AuthProvider['getRestoreRedirectNotice']; }): AuthProvider { const providerId = params.id.toString().trim().toLowerCase(); const providerName = params.displayName; @@ -24,6 +26,15 @@ export function createExternalOAuthProvider(params: { badgeIconName: params.badgeIconName, supportsProfileBadge: params.supportsProfileBadge, connectButtonColor: params.connectButtonColor, + getRestoreRedirectNotice: params.getRestoreRedirectNotice + ? params.getRestoreRedirectNotice + : ({ reason }) => { + if (reason !== 'provider_already_linked') return null; + return { + title: t('connect.externalAuthVerifiedTitle', { provider: providerName }), + body: t('connect.externalAuthVerifiedBody', { provider: providerName }), + }; + }, getExternalSignupUrl: async ({ publicKey }) => { const response = await serverFetch( `/v1/auth/external/${encodeURIComponent(providerId)}/params?publicKey=${encodeURIComponent(publicKey)}`, @@ -48,6 +59,35 @@ export function createExternalOAuthProvider(params: { } return String(data.url); }, + getExternalLoginUrl: async ({ proofHash }) => { + const normalizedProofHash = String(proofHash ?? '').trim(); + if (!normalizedProofHash) { + throw new Error('external-login-unavailable'); + } + + const response = await serverFetch( + `/v1/auth/external/${encodeURIComponent(providerId)}/params?mode=keyless&proofHash=${encodeURIComponent(normalizedProofHash)}`, + undefined, + { includeAuth: false }, + ); + if (!response.ok) { + if (response.status === 400) { + const error = await response.json().catch(() => null); + if (error?.error === OAUTH_NOT_CONFIGURED_ERROR) { + throw new HappyError(`${providerName} OAuth is not configured on this server.`, false, { + status: 400, + kind: 'config', + }); + } + } + throw new Error('external-login-unavailable'); + } + const data = (await response.json()) as any; + if (!data?.url) { + throw new Error('external-login-unavailable'); + } + return String(data.url); + }, getConnectUrl: async (credentials: AuthCredentials) => { return await backoff(async () => { const response = await serverFetch( diff --git a/apps/ui/sources/auth/providers/github/index.spec.ts b/apps/ui/sources/auth/providers/github/index.spec.ts index 16eba19ef..7fcbaee95 100644 --- a/apps/ui/sources/auth/providers/github/index.spec.ts +++ b/apps/ui/sources/auth/providers/github/index.spec.ts @@ -9,6 +9,13 @@ vi.mock('@/utils/timing/time', () => ({ backoff: async <T>(fn: () => Promise<T>) => await fn(), })); +vi.mock('@/text', () => ({ + t: (key: string, params?: Record<string, unknown>) => { + const provider = typeof params?.provider === 'string' ? params.provider : ''; + return provider ? `${key}:${provider}` : key; + }, +})); + import { githubAuthProvider } from './index'; type MockFetchResponse = { @@ -48,6 +55,13 @@ describe('githubAuthProvider', () => { expect(githubAuthProvider.connectButtonColor).toBe('#24292e'); }); + it('provides a restore notice for provider_already_linked redirects', () => { + expect(githubAuthProvider.getRestoreRedirectNotice?.({ reason: 'provider_already_linked' })).toEqual({ + title: 'connect.externalAuthVerifiedTitle:GitHub', + body: 'connect.externalAuthVerifiedBody:GitHub', + }); + }); + it.each([ { label: 'string payload', body: { success: 'yes' } }, { label: 'numeric payload', body: { success: 1 } }, diff --git a/apps/ui/sources/auth/providers/github/oauth.auth.spec.tsx b/apps/ui/sources/auth/providers/github/oauth.auth.spec.tsx index d414e0456..cb46dea31 100644 --- a/apps/ui/sources/auth/providers/github/oauth.auth.spec.tsx +++ b/apps/ui/sources/auth/providers/github/oauth.auth.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; import { flushOAuthEffects, localSearchParamsMock, @@ -7,8 +8,11 @@ import { modal, replaceSpy, resetOAuthHarness, + clearPendingExternalAuthMock, runWithOAuthScreen, setPendingExternalAuthState, + setActiveServerSnapshot, + upsertAndActivateServerSpy, } from './test/oauthReturnHarness'; type FetchResult = { @@ -47,6 +51,122 @@ afterEach(() => { }); describe('/oauth/[provider] (auth flow)', () => { + it('uses the pending external auth serverUrl for finalize requests when present', async () => { + setPendingExternalAuthState({ provider: 'github', secret: OAUTH_SECRET, serverUrl: 'http://api.example.test' }); + replaceSpy.mockReset(); + loginSpy.mockClear(); + modal.alert.mockClear(); + modal.prompt.mockReset(); + + localSearchParamsMock.mockReturnValue({ + provider: 'github', + flow: 'auth', + pending: 'p1', + }); + + const fetchMock = stubFetch(async (url, init) => { + if (url === 'http://api.example.test/v1/auth/external/github/finalize') { + expect(init?.method).toBe('POST'); + return { ok: true, body: { success: true, token: 'tok_1' } }; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + await runWithOAuthScreen(async () => { + await flushOAuthEffects(); + expect(fetchMock).toHaveBeenCalled(); + expect(loginSpy).toHaveBeenCalledWith('tok_1', OAUTH_SECRET); + expect(replaceSpy).toHaveBeenCalledWith('/'); + }); + }); + + it('falls back to resolving the provider id from window.location.pathname', async () => { + setPendingExternalAuthState({ provider: 'github', secret: OAUTH_SECRET }); + replaceSpy.mockReset(); + loginSpy.mockClear(); + modal.alert.mockClear(); + modal.prompt.mockReset(); + + localSearchParamsMock.mockReturnValue({ + flow: 'auth', + pending: 'p1', + }); + + const originalWindow = (globalThis as any).window; + (globalThis as any).window = { + location: { + pathname: '/oauth/github', + }, + }; + + const fetchMock = stubFetch(async (url, init) => { + if (url.endsWith('/v1/auth/external/github/finalize')) { + expect(init?.method).toBe('POST'); + return { ok: true, body: { success: true, token: 'tok_1' } }; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + try { + await runWithOAuthScreen(async () => { + await flushOAuthEffects(); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/v1/auth/external/github/finalize'), + expect.anything(), + ); + expect(loginSpy).toHaveBeenCalledWith('tok_1', OAUTH_SECRET); + expect(replaceSpy).toHaveBeenCalledWith('/'); + }); + } finally { + (globalThis as any).window = originalWindow; + } + }); + + it('falls back to resolving query params from window.location.search on cold start', async () => { + setPendingExternalAuthState({ provider: 'github', secret: OAUTH_SECRET }); + replaceSpy.mockReset(); + loginSpy.mockClear(); + modal.alert.mockClear(); + modal.prompt.mockReset(); + + // Some web cold-starts/hydration paths can temporarily omit search params from useLocalSearchParams. + localSearchParamsMock.mockReturnValue({ + provider: 'github', + }); + + const originalWindow = (globalThis as any).window; + (globalThis as any).window = { + location: { + pathname: '/oauth/github', + search: '?flow=auth&pending=p1', + }, + }; + + const fetchMock = stubFetch(async (url, init) => { + if (url.endsWith('/v1/auth/external/github/finalize')) { + expect(init?.method).toBe('POST'); + const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>; + expect(body.pending).toBe('p1'); + return { ok: true, body: { success: true, token: 'tok_1' } }; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + try { + await runWithOAuthScreen(async () => { + await flushOAuthEffects(); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/v1/auth/external/github/finalize'), + expect.anything(), + ); + expect(loginSpy).toHaveBeenCalledWith('tok_1', OAUTH_SECRET); + expect(replaceSpy).toHaveBeenCalledWith('/'); + }); + } finally { + (globalThis as any).window = originalWindow; + } + }); + it('finalizes external auth and logs in when flow=auth', async () => { setPendingExternalAuthState({ provider: 'github', secret: OAUTH_SECRET }); replaceSpy.mockReset(); @@ -80,17 +200,16 @@ describe('/oauth/[provider] (auth flow)', () => { expect.anything(), ); expect(loginSpy).toHaveBeenCalledWith('tok_1', OAUTH_SECRET); - expect(replaceSpy).toHaveBeenCalledWith('/friends'); + expect(replaceSpy).toHaveBeenCalledWith('/'); }); }); - it('prompts for username and includes it in finalize when status=username_required', async () => { + it('renders a username form and includes it in finalize when status=username_required', async () => { setPendingExternalAuthState({ provider: 'github', secret: OAUTH_SECRET }); replaceSpy.mockReset(); loginSpy.mockClear(); modal.alert.mockClear(); modal.prompt.mockReset(); - modal.prompt.mockResolvedValueOnce('octocat_2'); localSearchParamsMock.mockReturnValue({ provider: 'github', @@ -101,7 +220,7 @@ describe('/oauth/[provider] (auth flow)', () => { pending: 'p1', }); - stubFetch(async (url, init) => { + const fetchMock = stubFetch(async (url, init) => { if (url.endsWith('/v1/auth/external/github/finalize')) { const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>; expect(body.username).toBe('octocat_2'); @@ -110,18 +229,64 @@ describe('/oauth/[provider] (auth flow)', () => { throw new Error(`Unexpected fetch: ${url}`); }); + await runWithOAuthScreen(async (tree) => { + await flushOAuthEffects(); + expect(fetchMock).not.toHaveBeenCalled(); + + const input = tree.root.findByProps({ testID: 'oauth-username-input' }); + act(() => { + input.props.onChangeText('octocat_2'); + }); + + const save = tree.root.findByProps({ testID: 'oauth-username-save' }); + await act(async () => { + await save.props.onPress(); + }); + await flushOAuthEffects(); + + expect(loginSpy).toHaveBeenCalledWith('tok_1', OAUTH_SECRET); + expect(replaceSpy).toHaveBeenCalledWith('/'); + }); + }); + + it('redirects to the pending external auth returnTo after login when provided', async () => { + setPendingExternalAuthState({ provider: 'github', secret: OAUTH_SECRET, returnTo: '/settings/account' }); + replaceSpy.mockReset(); + loginSpy.mockClear(); + modal.alert.mockClear(); + modal.prompt.mockReset(); + + localSearchParamsMock.mockReturnValue({ + provider: 'github', + flow: 'auth', + pending: 'p1', + }); + + stubFetch(async (url) => { + if (url.endsWith('/v1/auth/external/github/finalize')) { + return { ok: true, body: { success: true, token: 'tok_1' } }; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + await runWithOAuthScreen(async () => { await flushOAuthEffects(); - expect(modal.prompt).toHaveBeenCalled(); expect(loginSpy).toHaveBeenCalledWith('tok_1', OAUTH_SECRET); - expect(replaceSpy).toHaveBeenCalledWith('/friends'); + expect(replaceSpy).toHaveBeenCalledWith('/settings/account'); }); }); it('includes reset=true in finalize when pending external auth intent=reset', async () => { - setPendingExternalAuthState({ provider: 'github', secret: OAUTH_SECRET, intent: 'reset' }); + setActiveServerSnapshot({ serverUrl: 'http://default.example.test' }); + setPendingExternalAuthState({ + provider: 'github', + secret: OAUTH_SECRET, + intent: 'reset', + serverUrl: 'http://api.example.test', + }); replaceSpy.mockReset(); loginSpy.mockClear(); + upsertAndActivateServerSpy.mockClear(); modal.alert.mockClear(); modal.prompt.mockReset(); @@ -146,7 +311,140 @@ describe('/oauth/[provider] (auth flow)', () => { expect.stringContaining('/v1/auth/external/github/finalize'), expect.anything(), ); + expect(upsertAndActivateServerSpy).toHaveBeenCalledWith( + expect.objectContaining({ serverUrl: 'http://api.example.test' }), + ); + expect(loginSpy).toHaveBeenCalledWith('tok_1', OAUTH_SECRET); + }); + }); + + it('logs in and redirects even if the effect is cancelled by a params re-render (success token)', async () => { + setPendingExternalAuthState({ provider: 'github', secret: OAUTH_SECRET }); + replaceSpy.mockReset(); + loginSpy.mockClear(); + modal.alert.mockClear(); + modal.prompt.mockReset(); + clearPendingExternalAuthMock.mockClear(); + + localSearchParamsMock.mockReturnValue({ + provider: 'github', + flow: 'auth', + pending: 'p1', + }); + + let resolveFinalize: ((result: FetchResult) => void) | null = null; + const finalizeDeferred = new Promise<FetchResult>((resolve) => { + resolveFinalize = resolve; + }); + + const fetchMock = stubFetch(async (url) => { + if (url.endsWith('/v1/auth/external/github/finalize')) { + return await finalizeDeferred; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.resetModules(); + const { default: Screen } = await import('@/app/(app)/oauth/[provider]'); + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create(React.createElement(Screen)); + }); + if (!tree) throw new Error('Expected OAuth screen to render'); + const ensuredTree = tree; + try { + await flushOAuthEffects(); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/v1/auth/external/github/finalize'), expect.anything()); + + // Simulate expo-router updating params after hydration; this cancels the first effect run. + localSearchParamsMock.mockReturnValue({ + provider: 'github', + flow: 'auth', + pending: 'p1', + hydrated: '1', + }); + act(() => { + ensuredTree.update(React.createElement(Screen)); + }); + + await act(async () => { + resolveFinalize?.({ ok: true, status: 200, body: { token: 'tok_1' } }); + }); + await flushOAuthEffects(); + + expect(clearPendingExternalAuthMock).toHaveBeenCalled(); expect(loginSpy).toHaveBeenCalledWith('tok_1', OAUTH_SECRET); + expect(replaceSpy).toHaveBeenCalledWith('/'); + } finally { + act(() => { + ensuredTree.unmount(); + }); + } + }); + + it('redirects to restore even if the effect is cancelled by a params re-render (provider already linked)', async () => { + setPendingExternalAuthState({ provider: 'github', secret: OAUTH_SECRET }); + replaceSpy.mockReset(); + loginSpy.mockClear(); + modal.alert.mockClear(); + modal.prompt.mockReset(); + clearPendingExternalAuthMock.mockClear(); + + localSearchParamsMock.mockReturnValue({ + provider: 'github', + flow: 'auth', + pending: 'p1', + }); + + let resolveFinalize: ((result: FetchResult) => void) | null = null; + const finalizeDeferred = new Promise<FetchResult>((resolve) => { + resolveFinalize = resolve; }); + + const fetchMock = stubFetch(async (url) => { + if (url.endsWith('/v1/auth/external/github/finalize')) { + return await finalizeDeferred; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.resetModules(); + const { default: Screen } = await import('@/app/(app)/oauth/[provider]'); + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create(React.createElement(Screen)); + }); + if (!tree) throw new Error('Expected OAuth screen to render'); + const ensuredTree = tree; + try { + await flushOAuthEffects(); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/v1/auth/external/github/finalize'), expect.anything()); + + // Simulate expo-router updating params after hydration; this cancels the first effect run + // (cleanup sets cancelled=true) and previously could suppress navigation. + localSearchParamsMock.mockReturnValue({ + provider: 'github', + flow: 'auth', + pending: 'p1', + hydrated: '1', + }); + act(() => { + ensuredTree.update(React.createElement(Screen)); + }); + + await act(async () => { + resolveFinalize?.({ ok: false, status: 409, body: { error: 'provider-already-linked', provider: 'github' } }); + }); + await flushOAuthEffects(); + + expect(clearPendingExternalAuthMock).toHaveBeenCalled(); + expect(replaceSpy).toHaveBeenCalledWith('/restore?provider=github&reason=provider_already_linked'); + } finally { + act(() => { + ensuredTree.unmount(); + }); + } }); }); diff --git a/apps/ui/sources/auth/providers/github/test/oauthReturnHarness.ts b/apps/ui/sources/auth/providers/github/test/oauthReturnHarness.ts index e892b1993..f8df518da 100644 --- a/apps/ui/sources/auth/providers/github/test/oauthReturnHarness.ts +++ b/apps/ui/sources/auth/providers/github/test/oauthReturnHarness.ts @@ -7,6 +7,8 @@ export const replaceSpy = vi.fn(); export const localSearchParamsMock = vi.fn(); export const loginSpy = vi.fn(async () => {}); +export const loginWithCredentialsSpy = vi.fn(async () => {}); +export const upsertAndActivateServerSpy = vi.fn(); const hoistedModal = vi.hoisted(() => ({ alert: vi.fn(async () => {}), prompt: vi.fn<(title: string, message: string, opts: Record<string, unknown>) => Promise<string | null>>(async () => null), @@ -14,6 +16,22 @@ const hoistedModal = vi.hoisted(() => ({ })); export const modal = hoistedModal; +let activeServerSnapshotState: { + serverId: string; + serverUrl: string; + kind: string; + generation: number; +} = { + serverId: 'server-a', + serverUrl: 'http://default.example.test', + kind: 'custom', + generation: 1, +}; + +export function setActiveServerSnapshot(next: Partial<typeof activeServerSnapshotState>) { + activeServerSnapshotState = { ...activeServerSnapshotState, ...next }; +} + let pendingExternalAuthState: PendingExternalAuth | null = { provider: 'github', secret: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', @@ -54,18 +72,25 @@ vi.mock('@/auth/context/AuthContext', () => ({ isAuthenticated: authState.isAuthenticated, credentials: authState.credentials, login: loginSpy, + loginWithCredentials: loginWithCredentialsSpy, logout: vi.fn(async () => {}), }), })); vi.mock('@/modal', () => ({ Modal: modal })); +vi.mock('@/sync/domains/server/serverRuntime', () => ({ + getActiveServerSnapshot: () => activeServerSnapshotState, + upsertAndActivateServer: upsertAndActivateServerSpy, +})); + vi.mock('@/sync/api/capabilities/sessionSharingSupport', () => ({ isSessionSharingSupported: async () => false, })); vi.mock('@/platform/cryptoRandom', () => ({ getRandomBytes: () => new Uint8Array(32).fill(9), + getRandomBytesAsync: async () => new Uint8Array(32).fill(9), })); vi.mock('@/auth/storage/tokenStorage', async () => { @@ -89,6 +114,10 @@ vi.mock('@/encryption/libsodium.lib', () => ({ privateKey: new Uint8Array(64).fill(2), }), crypto_sign_detached: (_message: Uint8Array, _privateKey: Uint8Array) => new Uint8Array(64).fill(3), + crypto_box_seed_keypair: (_seed: Uint8Array) => ({ + publicKey: new Uint8Array(32).fill(4), + privateKey: new Uint8Array(32).fill(5), + }), }, })); @@ -127,6 +156,8 @@ export async function renderOAuthReturnScreen() { export function resetOAuthHarness() { replaceSpy.mockReset(); loginSpy.mockReset(); + loginWithCredentialsSpy.mockReset(); + upsertAndActivateServerSpy.mockReset(); if (typeof modal.alert.mockReset === 'function') { modal.alert.mockReset(); } else { @@ -157,4 +188,10 @@ export function resetOAuthHarness() { isAuthenticated: false, credentials: null, }); + setActiveServerSnapshot({ + serverId: 'server-a', + serverUrl: 'http://default.example.test', + kind: 'custom', + generation: 1, + }); } diff --git a/apps/ui/sources/auth/providers/registry.fallback.spec.ts b/apps/ui/sources/auth/providers/registry.fallback.spec.ts index 822ca1ad7..85660b275 100644 --- a/apps/ui/sources/auth/providers/registry.fallback.spec.ts +++ b/apps/ui/sources/auth/providers/registry.fallback.spec.ts @@ -29,7 +29,7 @@ function buildCachedFeatures( oauth: { providers: { [providerId]: { enabled: true, configured: true } } }, auth: { signup: { methods: [{ id: providerId, enabled: true }] }, - login: { requiredProviders: [] }, + login: { methods: [{ id: 'key_challenge', enabled: true }], requiredProviders: [] }, providers: { [providerId]: { enabled: true, diff --git a/apps/ui/sources/auth/providers/types.ts b/apps/ui/sources/auth/providers/types.ts index 6843ffa3e..20fff9349 100644 --- a/apps/ui/sources/auth/providers/types.ts +++ b/apps/ui/sources/auth/providers/types.ts @@ -1,13 +1,22 @@ import type { AuthCredentials } from '@/auth/storage/tokenStorage'; import type { AuthProviderId } from '@happier-dev/protocol'; +export type RestoreRedirectReason = 'provider_already_linked'; + +export type RestoreRedirectNotice = Readonly<{ + title: string; + body: string; +}>; + export type AuthProvider = Readonly<{ id: AuthProviderId; displayName?: string; badgeIconName?: string; supportsProfileBadge?: boolean; connectButtonColor?: string; + getRestoreRedirectNotice?: (params: { reason: RestoreRedirectReason }) => RestoreRedirectNotice | null; getExternalSignupUrl: (params: { publicKey: string }) => Promise<string>; + getExternalLoginUrl?: (params: { proofHash: string }) => Promise<string>; getConnectUrl: (credentials: AuthCredentials) => Promise<string>; finalizeConnect: (credentials: AuthCredentials, params: { pending: string; username: string }) => Promise<void>; cancelConnectPending: (credentials: AuthCredentials, pending: string) => Promise<void>; diff --git a/apps/ui/sources/auth/recovery/secretKeyBackup.spec.ts b/apps/ui/sources/auth/recovery/secretKeyBackup.spec.ts index bb26f16ac..e3ba6c5b3 100644 --- a/apps/ui/sources/auth/recovery/secretKeyBackup.spec.ts +++ b/apps/ui/sources/auth/recovery/secretKeyBackup.spec.ts @@ -10,6 +10,7 @@ import { patternedSecretBase64, sequentialSecretBase64, sequentialSecretBytes, + toBase64Url, } from './secretKeyBackup.testHelpers'; describe('secretKeyBackup format/parse', () => { @@ -43,4 +44,19 @@ describe('secretKeyBackup format/parse', () => { expect(normalizeSecretKey(sequentialSecretBase64)).toBe(sequentialSecretBase64); expect(normalizeSecretKey(formatted)).toBe(sequentialSecretBase64); }); + + it("does not treat base64url keys containing '-' as formatted backup keys", () => { + const findBase64UrlWithDash = (): string => { + for (let offset = 0; offset < 256; offset += 1) { + const candidate = toBase64Url(new Uint8Array(32).fill(offset)); + if (candidate.includes('-')) return candidate; + } + throw new Error("Unable to generate a base64url key containing '-'"); + }; + + const base64Url = findBase64UrlWithDash(); + expect(base64Url.includes('-')).toBe(true); + expect(decodeBase64(base64Url, 'base64url')).toHaveLength(32); + expect(normalizeSecretKey(base64Url)).toBe(base64Url); + }); }); diff --git a/apps/ui/sources/auth/recovery/secretKeyBackup.ts b/apps/ui/sources/auth/recovery/secretKeyBackup.ts index 0e6eaa48d..36bb01ac1 100644 --- a/apps/ui/sources/auth/recovery/secretKeyBackup.ts +++ b/apps/ui/sources/auth/recovery/secretKeyBackup.ts @@ -137,14 +137,18 @@ export function parseBackupSecretKey(formattedKey: string): string { */ export function isValidSecretKey(key: string): boolean { try { - // Try parsing as formatted key first - if (key.includes('-')) { - const parsed = parseBackupSecretKey(key); - return decodeBase64(parsed, 'base64url').length === 32; + // Prefer base64url validation first. Base64url secrets may contain `-` and `_`, + // so we must not treat `-` as an automatic signal for base32 backup format. + if (decodeBase64(key, 'base64url').length === 32) { + return true; } + } catch { + // ignore and fall back to backup parsing below + } - // Try as base64url - return decodeBase64(key, 'base64url').length === 32; + try { + parseBackupSecretKey(key); + return true; } catch { return false; } @@ -158,22 +162,18 @@ export function isValidSecretKey(key: string): boolean { export function normalizeSecretKey(key: string): string { // Trim whitespace const trimmed = key.trim(); - - // Check if it looks like a formatted key (contains dashes or spaces between groups) - // or has been typed with spaces/formatting - if (/[-\s]/.test(trimmed) || trimmed.length > 50) { - return parseBackupSecretKey(trimmed); - } - // Otherwise try to parse as base64url + // Prefer base64url normalization first. Base64url secrets may contain `-` and `_`, + // so we must not treat the presence of `-` as an automatic signal for base32 backup format. try { const bytes = decodeBase64(trimmed, 'base64url'); - if (bytes.length !== 32) { - throw new Error('Invalid secret key'); + if (bytes.length === 32) { + return encodeBase64(bytes, 'base64url'); } - return trimmed; - } catch (error) { - // If base64 parsing fails, try parsing as formatted key anyway - return parseBackupSecretKey(trimmed); + } catch { + // ignore and fall back to backup parsing below } -} \ No newline at end of file + + // If base64url decoding doesn't produce a 32-byte seed, fall back to the base32 backup format. + return parseBackupSecretKey(trimmed); +} diff --git a/apps/ui/sources/auth/recovery/secretKeyBackup.validation.spec.ts b/apps/ui/sources/auth/recovery/secretKeyBackup.validation.spec.ts index 941c6aef0..ad2e6494e 100644 --- a/apps/ui/sources/auth/recovery/secretKeyBackup.validation.spec.ts +++ b/apps/ui/sources/auth/recovery/secretKeyBackup.validation.spec.ts @@ -10,6 +10,7 @@ import { import { fullFFSecretBase64, sequentialSecretBase64, + toBase64Url, } from './secretKeyBackup.testHelpers'; describe('secretKeyBackup validation', () => { @@ -20,6 +21,20 @@ describe('secretKeyBackup validation', () => { expect(isValidSecretKey(formatted)).toBe(true); }); + it("accepts base64url secrets that include '-' characters", () => { + const findBase64UrlWithDash = (): string => { + for (let offset = 0; offset < 256; offset += 1) { + const candidate = toBase64Url(new Uint8Array(32).fill(offset)); + if (candidate.includes('-')) return candidate; + } + throw new Error("Unable to generate a base64url key containing '-'"); + }; + + const base64Url = findBase64UrlWithDash(); + expect(base64Url.includes('-')).toBe(true); + expect(isValidSecretKey(base64Url)).toBe(true); + }); + it('rejects malformed and empty keys', () => { expect(isValidSecretKey('')).toBe(false); expect(isValidSecretKey(' ')).toBe(false); diff --git a/apps/ui/sources/auth/routing/authRouting.test.ts b/apps/ui/sources/auth/routing/authRouting.test.ts index ed389addc..899de9421 100644 --- a/apps/ui/sources/auth/routing/authRouting.test.ts +++ b/apps/ui/sources/auth/routing/authRouting.test.ts @@ -16,6 +16,8 @@ describe('isPublicRouteForUnauthenticated', () => { { name: 'nested share route', segments: ['(app)', 'share', 'abc123'], expected: true }, { name: 'terminal route', segments: ['terminal'], expected: true }, { name: 'nested terminal route', segments: ['(app)', 'terminal', 'connect'], expected: true }, + { name: 'oauth return route', segments: ['oauth', 'github'], expected: true }, + { name: 'grouped oauth return route', segments: ['(app)', 'oauth', 'github'], expected: true }, { name: 'private settings route', segments: ['settings'], expected: false }, { name: 'grouped private settings route', segments: ['(app)', 'settings'], expected: false }, { name: 'unknown private route', segments: ['inbox'], expected: false }, diff --git a/apps/ui/sources/auth/routing/authRouting.ts b/apps/ui/sources/auth/routing/authRouting.ts index 3d9210e0c..58e670e81 100644 --- a/apps/ui/sources/auth/routing/authRouting.ts +++ b/apps/ui/sources/auth/routing/authRouting.ts @@ -17,6 +17,9 @@ export function isPublicRouteForUnauthenticated(segments: string[]): boolean { // Restore / link account flows must work unauthenticated. if (first === 'restore') return true; + // OAuth return routes must be reachable before authentication so the callback can finalize. + if (first === 'oauth') return true; + // Public share links must work unauthenticated. if (first === 'share') return true; diff --git a/apps/ui/sources/auth/storage/tokenStorage.pendingExternalAuth.test.ts b/apps/ui/sources/auth/storage/tokenStorage.pendingExternalAuth.test.ts index 2cb55c2c9..00c65b856 100644 --- a/apps/ui/sources/auth/storage/tokenStorage.pendingExternalAuth.test.ts +++ b/apps/ui/sources/auth/storage/tokenStorage.pendingExternalAuth.test.ts @@ -39,11 +39,49 @@ describe('TokenStorage pending external auth (web)', () => { await expect(TokenStorage.getPendingExternalAuth()).resolves.toEqual({ provider: 'github', secret: 's' }); + if (!localStorageHandle) { + throw new Error('Expected localStorage mock handle'); + } + const pendingKeys = [...localStorageHandle.store.keys()].filter((k) => k.includes('pending_external_auth')); + expect(pendingKeys.length).toBe(2); + expect(pendingKeys.some((k) => k.includes('__srv_'))).toBe(true); + expect(pendingKeys.some((k) => k.includes('__global'))).toBe(true); + + // If the server-scoped key can't be resolved on return (server selection changed / lost), + // TokenStorage should still recover the pending state from the global fallback. + for (const key of pendingKeys) { + if (key.includes('__srv_')) { + localStorageHandle.store.delete(key); + } + } + await expect(TokenStorage.getPendingExternalAuth()).resolves.toEqual({ provider: 'github', secret: 's' }); + const cleared = await TokenStorage.clearPendingExternalAuth(); expect(cleared).toBe(true); await expect(TokenStorage.getPendingExternalAuth()).resolves.toBeNull(); }); + it('round-trips pending external auth state for keyless external login', async () => { + const { TokenStorage } = await import('./tokenStorage'); + + const ok = await TokenStorage.setPendingExternalAuth({ provider: 'github', proof: 'p', mode: 'keyless' }); + expect(ok).toBe(true); + + await expect(TokenStorage.getPendingExternalAuth()).resolves.toEqual({ provider: 'github', proof: 'p', mode: 'keyless' }); + }); + + it('round-trips pending external auth returnTo when it is an internal path', async () => { + const { TokenStorage } = await import('./tokenStorage'); + + const ok = await TokenStorage.setPendingExternalAuth({ provider: 'github', secret: 's', returnTo: '/settings/account' }); + expect(ok).toBe(true); + await expect(TokenStorage.getPendingExternalAuth()).resolves.toEqual({ + provider: 'github', + secret: 's', + returnTo: '/settings/account', + }); + }); + it('returns null for malformed pending external auth payloads', async () => { const { TokenStorage } = await import('./tokenStorage'); diff --git a/apps/ui/sources/auth/storage/tokenStorage.ts b/apps/ui/sources/auth/storage/tokenStorage.ts index 78a48495c..5995b1d5b 100644 --- a/apps/ui/sources/auth/storage/tokenStorage.ts +++ b/apps/ui/sources/auth/storage/tokenStorage.ts @@ -7,6 +7,7 @@ import { encodeBase64 } from '@/encryption/base64'; const AUTH_KEY = 'auth_credentials'; const PENDING_EXTERNAL_AUTH_KEY = 'pending_external_auth'; +const PENDING_EXTERNAL_AUTH_GLOBAL_KEY = 'pending_external_auth__global'; const PENDING_EXTERNAL_CONNECT_KEY = 'pending_external_connect'; const AUTH_AUTO_REDIRECT_SUPPRESSED_UNTIL_KEY = 'auth_auto_redirect_suppressed_until'; const AUTH_AUTO_REDIRECT_SUPPRESSED_UNTIL_GLOBAL_KEY = 'auth_auto_redirect_suppressed_until_global'; @@ -140,6 +141,11 @@ async function getPendingExternalAuthKey(): Promise<string> { return (await getServerScopedKeys(PENDING_EXTERNAL_AUTH_KEY)).primary; } +function getPendingExternalAuthGlobalKey(): string { + const scope = Platform.OS === 'web' ? null : readStorageScopeFromEnv(); + return scopedStorageId(PENDING_EXTERNAL_AUTH_GLOBAL_KEY, scope); +} + async function getPendingExternalConnectKey(): Promise<string> { return (await getServerScopedKeys(PENDING_EXTERNAL_CONNECT_KEY)).primary; } @@ -179,8 +185,12 @@ export function isLegacyAuthCredentials(credentials: AuthCredentials): credentia export interface PendingExternalAuth { provider: string; - secret: string; + secret?: string; + proof?: string; + mode?: 'keyed' | 'keyless'; intent?: 'signup' | 'reset'; + serverUrl?: string; + returnTo?: string; } export interface PendingExternalConnect { @@ -192,10 +202,32 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +function isInternalReturnTo(value: unknown): value is string { + if (!isNonEmptyString(value)) return false; + const trimmed = value.trim(); + if (!trimmed.startsWith('/')) return false; + // Prevent protocol-relative URLs. + if (trimmed.startsWith('//')) return false; + return true; +} + function isPendingExternalAuthRecord(value: unknown): value is PendingExternalAuth { if (!value || typeof value !== 'object') return false; const maybe = value as Record<string, unknown>; - if (!isNonEmptyString(maybe.provider) || !isNonEmptyString(maybe.secret)) return false; + if (!isNonEmptyString(maybe.provider)) return false; + const secret = maybe.secret; + const proof = maybe.proof; + const mode = maybe.mode; + const hasSecret = isNonEmptyString(secret); + const hasProof = isNonEmptyString(proof); + if (hasSecret === hasProof) return false; + if (hasProof) { + if (mode !== 'keyless') return false; + } else if (mode !== undefined && mode !== 'keyed') { + return false; + } + if (maybe.serverUrl !== undefined && !isNonEmptyString(maybe.serverUrl)) return false; + if (maybe.returnTo !== undefined && !isInternalReturnTo(maybe.returnTo)) return false; if (maybe.intent === undefined) return true; return maybe.intent === 'signup' || maybe.intent === 'reset'; } @@ -621,17 +653,28 @@ export const TokenStorage = { async getPendingExternalAuth(): Promise<PendingExternalAuth | null> { const key = await getPendingExternalAuthKey(); - return await readStoredJson(key, 'pending external auth', isPendingExternalAuthRecord); + const scoped = await readStoredJson(key, 'pending external auth', isPendingExternalAuthRecord); + if (scoped) return scoped; + const globalKey = getPendingExternalAuthGlobalKey(); + return await readStoredJson(globalKey, 'pending external auth', isPendingExternalAuthRecord); }, async setPendingExternalAuth(value: PendingExternalAuth): Promise<boolean> { const key = await getPendingExternalAuthKey(); - return await writeStoredJson(key, 'pending external auth', value); + const ok = await writeStoredJson(key, 'pending external auth', value); + if (ok) { + const globalKey = getPendingExternalAuthGlobalKey(); + await writeStoredJson(globalKey, 'pending external auth', value).catch(() => false); + } + return ok; }, async clearPendingExternalAuth(): Promise<boolean> { const key = await getPendingExternalAuthKey(); - return await removeStoredValue(key, 'pending external auth'); + const ok = await removeStoredValue(key, 'pending external auth'); + const globalKey = getPendingExternalAuthGlobalKey(); + await removeStoredValue(globalKey, 'pending external auth').catch(() => false); + return ok; }, async getPendingExternalConnect(): Promise<PendingExternalConnect | null> { diff --git a/apps/ui/sources/capabilities/codexAcpDep.test.ts b/apps/ui/sources/capabilities/codexAcpDep.test.ts index 24b914016..924638c18 100644 --- a/apps/ui/sources/capabilities/codexAcpDep.test.ts +++ b/apps/ui/sources/capabilities/codexAcpDep.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { CodexAcpDepData } from '@/sync/api/capabilities/capabilitiesProtocol'; +import { CODEX_ACP_DEP_ID, CODEX_ACP_DIST_TAG } from '@happier-dev/protocol/installables'; import { buildCodexAcpRegistryDetectRequest, - CODEX_ACP_DEP_ID, - CODEX_ACP_DIST_TAG, getCodexAcpDepData, getCodexAcpDetectResult, getCodexAcpLatestVersion, diff --git a/apps/ui/sources/capabilities/codexAcpDep.ts b/apps/ui/sources/capabilities/codexAcpDep.ts index e79ff3c66..0201e11a1 100644 --- a/apps/ui/sources/capabilities/codexAcpDep.ts +++ b/apps/ui/sources/capabilities/codexAcpDep.ts @@ -1,8 +1,6 @@ import type { CapabilitiesDetectRequest, CapabilityDetectResult, CapabilityId, CodexAcpDepData } from '@/sync/api/capabilities/capabilitiesProtocol'; import { compareVersions, parseVersion } from '@/utils/system/versionUtils'; - -export const CODEX_ACP_DEP_ID = 'dep.codex-acp' as const satisfies CapabilityId; -export const CODEX_ACP_DIST_TAG = 'latest' as const; +import { CODEX_ACP_DEP_ID, CODEX_ACP_DIST_TAG } from '@happier-dev/protocol/installables'; export function getCodexAcpDetectResult( results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined, diff --git a/apps/ui/sources/capabilities/codexMcpResume.test.ts b/apps/ui/sources/capabilities/codexMcpResume.test.ts index a20478b85..b84cc0ae1 100644 --- a/apps/ui/sources/capabilities/codexMcpResume.test.ts +++ b/apps/ui/sources/capabilities/codexMcpResume.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { CodexMcpResumeDepData } from '@/sync/api/capabilities/capabilitiesProtocol'; +import { CODEX_MCP_RESUME_DEP_ID, CODEX_MCP_RESUME_DIST_TAG } from '@happier-dev/protocol/installables'; import { buildCodexMcpResumeRegistryDetectRequest, - CODEX_MCP_RESUME_DEP_ID, - CODEX_MCP_RESUME_DIST_TAG, getCodexMcpResumeDepData, getCodexMcpResumeDetectResult, getCodexMcpResumeLatestVersion, diff --git a/apps/ui/sources/capabilities/codexMcpResume.ts b/apps/ui/sources/capabilities/codexMcpResume.ts index fdcde927e..1c40d61f0 100644 --- a/apps/ui/sources/capabilities/codexMcpResume.ts +++ b/apps/ui/sources/capabilities/codexMcpResume.ts @@ -1,8 +1,6 @@ import type { CapabilitiesDetectRequest, CapabilityDetectResult, CapabilityId, CodexMcpResumeDepData } from '@/sync/api/capabilities/capabilitiesProtocol'; import { compareVersions, parseVersion } from '@/utils/system/versionUtils'; - -export const CODEX_MCP_RESUME_DEP_ID = 'dep.codex-mcp-resume' as const satisfies CapabilityId; -export const CODEX_MCP_RESUME_DIST_TAG = 'happy-codex-resume' as const; +import { CODEX_MCP_RESUME_DEP_ID, CODEX_MCP_RESUME_DIST_TAG } from '@happier-dev/protocol/installables'; export function getCodexMcpResumeDetectResult( results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined, diff --git a/apps/ui/sources/capabilities/installableDepsRegistry.test.ts b/apps/ui/sources/capabilities/installableDepsRegistry.test.ts deleted file mode 100644 index 82ff11e66..000000000 --- a/apps/ui/sources/capabilities/installableDepsRegistry.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { getInstallableDepRegistryEntries } from './installableDepsRegistry'; -import { CODEX_MCP_RESUME_DEP_ID } from './codexMcpResume'; -import { CODEX_ACP_DEP_ID } from './codexAcpDep'; - -describe('getInstallableDepRegistryEntries', () => { - it('returns the expected built-in installable deps', () => { - const entries = getInstallableDepRegistryEntries(); - expect(entries.map((e) => e.depId)).toEqual([CODEX_MCP_RESUME_DEP_ID, CODEX_ACP_DEP_ID]); - expect(entries.map((e) => e.installSpecSettingKey)).toEqual(['codexMcpResumeInstallSpec', 'codexAcpInstallSpec']); - }); -}); diff --git a/apps/ui/sources/capabilities/installableDepsRegistry.ts b/apps/ui/sources/capabilities/installableDepsRegistry.ts deleted file mode 100644 index 7641002f8..000000000 --- a/apps/ui/sources/capabilities/installableDepsRegistry.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { CapabilitiesDetectRequest, CapabilityDetectResult, CapabilityId } from '@/sync/api/capabilities/capabilitiesProtocol'; -import type { KnownSettings } from '@/sync/domains/settings/settings'; -import type { TranslationKey } from '@/text'; -import type { CodexAcpDepData } from '@/sync/api/capabilities/capabilitiesProtocol'; -import type { CodexMcpResumeDepData } from '@/sync/api/capabilities/capabilitiesProtocol'; -import { t } from '@/text'; - -import { - buildCodexMcpResumeRegistryDetectRequest, - CODEX_MCP_RESUME_DEP_ID, - getCodexMcpResumeDepData, - getCodexMcpResumeDetectResult, - shouldPrefetchCodexMcpResumeRegistry, -} from './codexMcpResume'; -import { - buildCodexAcpRegistryDetectRequest, - CODEX_ACP_DEP_ID, - getCodexAcpDepData, - getCodexAcpDetectResult, - shouldPrefetchCodexAcpRegistry, -} from './codexAcpDep'; - -type SettingsKey = Extract<keyof KnownSettings, string>; - -export type InstallSpecSettingKey = { - [K in SettingsKey]: KnownSettings[K] extends string | null ? K : never; -}[SettingsKey] | 'codexMcpResumeInstallSpec' | 'codexAcpInstallSpec'; - -function readStringSetting(settings: KnownSettings, key: string): string | null { - const value = (settings as unknown as Record<string, unknown>)[key]; - return typeof value === 'string' ? value : null; -} - -export type InstallableDepDataLike = { - installed: boolean; - installedVersion: string | null; - distTag: string; - lastInstallLogPath: string | null; - registry?: { ok: true; latestVersion: string | null } | { ok: false; errorMessage: string }; -}; - -export type InstallableDepRegistryEntry = Readonly<{ - key: string; - experimental: boolean; - enabledWhen: (settings: KnownSettings) => boolean; - depId: Extract<CapabilityId, `dep.${string}`>; - depTitle: string; - depIconName: string; - groupTitleKey: TranslationKey; - installSpecSettingKey: InstallSpecSettingKey; - installSpecTitle: string; - installSpecDescription: string; - installLabels: { installKey: TranslationKey; updateKey: TranslationKey; reinstallKey: TranslationKey }; - installModal: { - installTitleKey: TranslationKey; - updateTitleKey: TranslationKey; - reinstallTitleKey: TranslationKey; - descriptionKey: TranslationKey; - }; - getDepStatus: (results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined) => InstallableDepDataLike | null; - getDetectResult: (results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined) => CapabilityDetectResult | null; - shouldPrefetchRegistry: (params: { - requireExistingResult?: boolean; - result?: CapabilityDetectResult | null; - data?: InstallableDepDataLike | null; - }) => boolean; - buildRegistryDetectRequest: () => CapabilitiesDetectRequest; -}>; - -export function getInstallableDepRegistryEntries(): readonly InstallableDepRegistryEntry[] { - const codexResume: InstallableDepRegistryEntry = { - key: 'codex-mcp-resume', - experimental: true, - enabledWhen: (settings) => readStringSetting(settings, 'codexBackendMode') === 'mcp_resume', - depId: CODEX_MCP_RESUME_DEP_ID, - depTitle: t('deps.installable.codexResume.title'), - depIconName: 'refresh-circle-outline', - groupTitleKey: 'newSession.codexResumeBanner.title', - installSpecSettingKey: 'codexMcpResumeInstallSpec', - installSpecTitle: t('deps.installable.codexResume.installSpecTitle'), - installSpecDescription: t('deps.installable.installSpecDescription'), - installLabels: { - installKey: 'newSession.codexResumeBanner.install', - updateKey: 'newSession.codexResumeBanner.update', - reinstallKey: 'newSession.codexResumeBanner.reinstall', - }, - installModal: { - installTitleKey: 'newSession.codexResumeInstallModal.installTitle', - updateTitleKey: 'newSession.codexResumeInstallModal.updateTitle', - reinstallTitleKey: 'newSession.codexResumeInstallModal.reinstallTitle', - descriptionKey: 'newSession.codexResumeInstallModal.description', - }, - getDepStatus: (results) => getCodexMcpResumeDepData(results) as unknown as CodexMcpResumeDepData | null, - getDetectResult: (results) => getCodexMcpResumeDetectResult(results), - shouldPrefetchRegistry: ({ requireExistingResult, result, data }) => - shouldPrefetchCodexMcpResumeRegistry({ - requireExistingResult, - result, - data: data as any, - }), - buildRegistryDetectRequest: buildCodexMcpResumeRegistryDetectRequest, - }; - - const codexAcp: InstallableDepRegistryEntry = { - key: 'codex-acp', - experimental: true, - enabledWhen: (settings) => readStringSetting(settings, 'codexBackendMode') === 'acp', - depId: CODEX_ACP_DEP_ID, - depTitle: t('deps.installable.codexAcp.title'), - depIconName: 'swap-horizontal-outline', - groupTitleKey: 'newSession.codexAcpBanner.title', - installSpecSettingKey: 'codexAcpInstallSpec', - installSpecTitle: t('deps.installable.codexAcp.installSpecTitle'), - installSpecDescription: t('deps.installable.installSpecDescription'), - installLabels: { - installKey: 'newSession.codexAcpBanner.install', - updateKey: 'newSession.codexAcpBanner.update', - reinstallKey: 'newSession.codexAcpBanner.reinstall', - }, - installModal: { - installTitleKey: 'newSession.codexAcpInstallModal.installTitle', - updateTitleKey: 'newSession.codexAcpInstallModal.updateTitle', - reinstallTitleKey: 'newSession.codexAcpInstallModal.reinstallTitle', - descriptionKey: 'newSession.codexAcpInstallModal.description', - }, - getDepStatus: (results) => getCodexAcpDepData(results) as unknown as CodexAcpDepData | null, - getDetectResult: (results) => getCodexAcpDetectResult(results), - shouldPrefetchRegistry: ({ requireExistingResult, result, data }) => - shouldPrefetchCodexAcpRegistry({ - requireExistingResult, - result, - data: data as any, - }), - buildRegistryDetectRequest: buildCodexAcpRegistryDetectRequest, - }; - - return [codexResume, codexAcp]; -} diff --git a/apps/ui/sources/capabilities/installablesBackgroundPlan.test.ts b/apps/ui/sources/capabilities/installablesBackgroundPlan.test.ts new file mode 100644 index 000000000..228ddd1a3 --- /dev/null +++ b/apps/ui/sources/capabilities/installablesBackgroundPlan.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { CODEX_ACP_DEP_ID, INSTALLABLE_KEYS } from '@happier-dev/protocol/installables'; + +import type { InstallableRegistryEntry, InstallableDepDataLike } from './installablesRegistry'; +import { planInstallablesBackgroundActions } from './installablesBackgroundPlan'; + +const baseEntry: InstallableRegistryEntry = { + key: INSTALLABLE_KEYS.CODEX_ACP, + kind: 'dep', + experimental: true, + enabledWhen: () => true, + capabilityId: CODEX_ACP_DEP_ID, + title: 'Codex ACP', + iconName: 'swap-horizontal-outline', + groupTitleKey: 'newSession.codexAcpBanner.title', + installSpecSettingKey: 'codexAcpInstallSpec', + installSpecTitle: 'Install source', + installSpecDescription: 'desc', + defaultPolicy: { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }, + installLabels: { + installKey: 'newSession.codexAcpBanner.install', + updateKey: 'newSession.codexAcpBanner.update', + reinstallKey: 'newSession.codexAcpBanner.reinstall', + }, + installModal: { + installTitleKey: 'newSession.codexAcpInstallModal.installTitle', + updateTitleKey: 'newSession.codexAcpInstallModal.updateTitle', + reinstallTitleKey: 'newSession.codexAcpInstallModal.reinstallTitle', + descriptionKey: 'newSession.codexAcpInstallModal.description', + }, + getStatus: () => null, + getDetectResult: () => null, + shouldPrefetchRegistry: () => false, + buildRegistryDetectRequest: () => ({ requests: [] }), +}; + +function status(data: Partial<InstallableDepDataLike>): InstallableDepDataLike { + return { + installed: false, + installedVersion: null, + distTag: 'latest', + lastInstallLogPath: null, + ...data, + }; +} + +describe('planInstallablesBackgroundActions', () => { + it('plans install when missing and autoInstallWhenNeeded=true', () => { + const actions = planInstallablesBackgroundActions({ + installables: [{ + entry: baseEntry, + status: status({ installed: false }), + policy: { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }, + installSpec: '', + }], + }); + expect(actions).toEqual([ + { installableKey: INSTALLABLE_KEYS.CODEX_ACP, request: { id: CODEX_ACP_DEP_ID, method: 'install' } }, + ]); + }); + + it('does not plan install when missing and autoInstallWhenNeeded=false', () => { + const actions = planInstallablesBackgroundActions({ + installables: [{ + entry: baseEntry, + status: status({ installed: false }), + policy: { autoInstallWhenNeeded: false, autoUpdateMode: 'auto' }, + installSpec: '', + }], + }); + expect(actions).toEqual([]); + }); + + it('plans upgrade when update available and autoUpdateMode=auto', () => { + const actions = planInstallablesBackgroundActions({ + installables: [{ + entry: baseEntry, + status: status({ + installed: true, + installedVersion: '1.0.0', + registry: { ok: true, latestVersion: '1.0.1' }, + }), + policy: { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }, + installSpec: '', + }], + }); + expect(actions).toEqual([ + { installableKey: INSTALLABLE_KEYS.CODEX_ACP, request: { id: CODEX_ACP_DEP_ID, method: 'upgrade' } }, + ]); + }); + + it('does not plan upgrade when update available and autoUpdateMode=notify', () => { + const actions = planInstallablesBackgroundActions({ + installables: [{ + entry: baseEntry, + status: status({ + installed: true, + installedVersion: '1.0.0', + registry: { ok: true, latestVersion: '1.0.1' }, + }), + policy: { autoInstallWhenNeeded: true, autoUpdateMode: 'notify' }, + installSpec: '', + }], + }); + expect(actions).toEqual([]); + }); +}); diff --git a/apps/ui/sources/capabilities/installablesBackgroundPlan.ts b/apps/ui/sources/capabilities/installablesBackgroundPlan.ts new file mode 100644 index 000000000..aae8281c5 --- /dev/null +++ b/apps/ui/sources/capabilities/installablesBackgroundPlan.ts @@ -0,0 +1,48 @@ +import type { CapabilitiesInvokeRequest } from '@/sync/api/capabilities/capabilitiesProtocol'; +import type { InstallableDefaultPolicy, InstallableDepDataLike, InstallableRegistryEntry } from './installablesRegistry'; +import { isInstallableDepUpdateAvailable } from './installablesUpdateAvailable'; + +export type InstallablesBackgroundAction = Readonly<{ + installableKey: string; + request: CapabilitiesInvokeRequest; +}>; + +export function planInstallablesBackgroundActions(params: { + installables: ReadonlyArray<Readonly<{ + entry: InstallableRegistryEntry; + status: InstallableDepDataLike | null; + policy: InstallableDefaultPolicy; + installSpec: string | null; + }>>; +}): InstallablesBackgroundAction[] { + const actions: InstallablesBackgroundAction[] = []; + + for (const item of params.installables) { + if (item.entry.kind !== 'dep') continue; + if (!item.status) continue; + + const installSpec = typeof item.installSpec === 'string' ? item.installSpec.trim() : ''; + const paramsObj = installSpec.length > 0 ? { installSpec } : undefined; + + if (item.status.installed !== true) { + if (item.policy.autoInstallWhenNeeded !== true) continue; + actions.push({ + installableKey: item.entry.key, + request: { id: item.entry.capabilityId, method: 'install', ...(paramsObj ? { params: paramsObj } : {}) }, + }); + continue; + } + + const updateAvailable = isInstallableDepUpdateAvailable(item.status); + if (!updateAvailable) continue; + if (item.policy.autoUpdateMode !== 'auto') continue; + + actions.push({ + installableKey: item.entry.key, + request: { id: item.entry.capabilityId, method: 'upgrade', ...(paramsObj ? { params: paramsObj } : {}) }, + }); + } + + return actions; +} + diff --git a/apps/ui/sources/capabilities/installablesRegistry.test.ts b/apps/ui/sources/capabilities/installablesRegistry.test.ts new file mode 100644 index 000000000..0d5d897a2 --- /dev/null +++ b/apps/ui/sources/capabilities/installablesRegistry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { INSTALLABLES_CATALOG } from '@happier-dev/protocol/installables'; + +import { getInstallablesRegistryEntries } from './installablesRegistry'; + +describe('getInstallablesRegistryEntries', () => { + it('returns the expected built-in installables', () => { + const entries = getInstallablesRegistryEntries(); + + expect(entries.map((e) => e.key)).toEqual(INSTALLABLES_CATALOG.map((e) => e.key)); + expect(entries.map((e) => e.capabilityId)).toEqual(INSTALLABLES_CATALOG.map((e) => e.capabilityId)); + expect(entries.map((e) => e.installSpecSettingKey)).toEqual(['codexMcpResumeInstallSpec', 'codexAcpInstallSpec']); + expect(entries.map((e) => e.defaultPolicy)).toEqual([ + { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }, + { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }, + ]); + }); +}); diff --git a/apps/ui/sources/capabilities/installablesRegistry.ts b/apps/ui/sources/capabilities/installablesRegistry.ts new file mode 100644 index 000000000..47936e657 --- /dev/null +++ b/apps/ui/sources/capabilities/installablesRegistry.ts @@ -0,0 +1,145 @@ +import type { CapabilitiesDetectRequest, CapabilityDetectResult, CapabilityId } from '@/sync/api/capabilities/capabilitiesProtocol'; +import type { KnownSettings } from '@/sync/domains/settings/settings'; +import type { TranslationKey } from '@/text'; +import type { CodexAcpDepData, CodexMcpResumeDepData } from '@/sync/api/capabilities/capabilitiesProtocol'; +import { t } from '@/text'; +import { INSTALLABLES_CATALOG, INSTALLABLE_KEYS, type InstallableAutoUpdateMode, type InstallableDefaultPolicy, type InstallableKey } from '@happier-dev/protocol/installables'; + +export type { InstallableAutoUpdateMode, InstallableDefaultPolicy }; + +import { + buildCodexMcpResumeRegistryDetectRequest, + getCodexMcpResumeDepData, + getCodexMcpResumeDetectResult, + shouldPrefetchCodexMcpResumeRegistry, +} from './codexMcpResume'; +import { + buildCodexAcpRegistryDetectRequest, + getCodexAcpDepData, + getCodexAcpDetectResult, + shouldPrefetchCodexAcpRegistry, +} from './codexAcpDep'; + +type SettingsKey = Extract<keyof KnownSettings, string>; + +export type InstallSpecSettingKey = { + [K in SettingsKey]: KnownSettings[K] extends string | null ? K : never; +}[SettingsKey] | 'codexMcpResumeInstallSpec' | 'codexAcpInstallSpec'; + +export type InstallableDepDataLike = { + installed: boolean; + installedVersion: string | null; + distTag: string; + lastInstallLogPath: string | null; + registry?: { ok: true; latestVersion: string | null } | { ok: false; errorMessage: string }; +}; + +export type InstallableRegistryEntry = Readonly<{ + key: string; + kind: 'dep'; + experimental: boolean; + enabledWhen: (settings: KnownSettings) => boolean; + capabilityId: Extract<CapabilityId, `dep.${string}`>; + title: string; + iconName: string; + groupTitleKey: TranslationKey; + installSpecSettingKey: InstallSpecSettingKey; + installSpecTitle: string; + installSpecDescription: string; + defaultPolicy: InstallableDefaultPolicy; + installLabels: { installKey: TranslationKey; updateKey: TranslationKey; reinstallKey: TranslationKey }; + installModal: { + installTitleKey: TranslationKey; + updateTitleKey: TranslationKey; + reinstallTitleKey: TranslationKey; + descriptionKey: TranslationKey; + }; + getStatus: (results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined) => InstallableDepDataLike | null; + getDetectResult: (results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined) => CapabilityDetectResult | null; + shouldPrefetchRegistry: (params: { + requireExistingResult?: boolean; + result?: CapabilityDetectResult | null; + data?: InstallableDepDataLike | null; + }) => boolean; + buildRegistryDetectRequest: () => CapabilitiesDetectRequest; +}>; + +export function getInstallablesRegistryEntries(): readonly InstallableRegistryEntry[] { + const uiByKey: Readonly<Record<InstallableKey, Omit<InstallableRegistryEntry, 'key' | 'kind' | 'experimental' | 'capabilityId' | 'defaultPolicy'>>> = { + [INSTALLABLE_KEYS.CODEX_MCP_RESUME]: { + enabledWhen: () => true, + title: t('deps.installable.codexResume.title'), + iconName: 'refresh-circle-outline', + groupTitleKey: 'newSession.codexResumeBanner.title', + installSpecSettingKey: 'codexMcpResumeInstallSpec', + installSpecTitle: t('deps.installable.codexResume.installSpecTitle'), + installSpecDescription: t('deps.installable.installSpecDescription'), + installLabels: { + installKey: 'newSession.codexResumeBanner.install', + updateKey: 'newSession.codexResumeBanner.update', + reinstallKey: 'newSession.codexResumeBanner.reinstall', + }, + installModal: { + installTitleKey: 'newSession.codexResumeInstallModal.installTitle', + updateTitleKey: 'newSession.codexResumeInstallModal.updateTitle', + reinstallTitleKey: 'newSession.codexResumeInstallModal.reinstallTitle', + descriptionKey: 'newSession.codexResumeInstallModal.description', + }, + getStatus: (results) => getCodexMcpResumeDepData(results) as unknown as CodexMcpResumeDepData | null, + getDetectResult: (results) => getCodexMcpResumeDetectResult(results), + shouldPrefetchRegistry: ({ requireExistingResult, result, data }) => + shouldPrefetchCodexMcpResumeRegistry({ + requireExistingResult, + result, + data: data as any, + }), + buildRegistryDetectRequest: buildCodexMcpResumeRegistryDetectRequest, + }, + [INSTALLABLE_KEYS.CODEX_ACP]: { + enabledWhen: () => true, + title: t('deps.installable.codexAcp.title'), + iconName: 'swap-horizontal-outline', + groupTitleKey: 'newSession.codexAcpBanner.title', + installSpecSettingKey: 'codexAcpInstallSpec', + installSpecTitle: t('deps.installable.codexAcp.installSpecTitle'), + installSpecDescription: t('deps.installable.installSpecDescription'), + installLabels: { + installKey: 'newSession.codexAcpBanner.install', + updateKey: 'newSession.codexAcpBanner.update', + reinstallKey: 'newSession.codexAcpBanner.reinstall', + }, + installModal: { + installTitleKey: 'newSession.codexAcpInstallModal.installTitle', + updateTitleKey: 'newSession.codexAcpInstallModal.updateTitle', + reinstallTitleKey: 'newSession.codexAcpInstallModal.reinstallTitle', + descriptionKey: 'newSession.codexAcpInstallModal.description', + }, + getStatus: (results) => getCodexAcpDepData(results) as unknown as CodexAcpDepData | null, + getDetectResult: (results) => getCodexAcpDetectResult(results), + shouldPrefetchRegistry: ({ requireExistingResult, result, data }) => + shouldPrefetchCodexAcpRegistry({ + requireExistingResult, + result, + data: data as any, + }), + buildRegistryDetectRequest: buildCodexAcpRegistryDetectRequest, + }, + }; + + const entries: InstallableRegistryEntry[] = []; + for (const catalogEntry of INSTALLABLES_CATALOG) { + if (catalogEntry.kind !== 'dep') continue; + const ui = uiByKey[catalogEntry.key as InstallableKey]; + if (!ui) continue; + entries.push({ + key: catalogEntry.key, + kind: 'dep', + experimental: catalogEntry.experimental, + capabilityId: catalogEntry.capabilityId, + defaultPolicy: catalogEntry.defaultPolicy, + ...ui, + }); + } + + return entries; +} diff --git a/apps/ui/sources/capabilities/installablesUpdateAvailable.ts b/apps/ui/sources/capabilities/installablesUpdateAvailable.ts new file mode 100644 index 000000000..a4c9eb031 --- /dev/null +++ b/apps/ui/sources/capabilities/installablesUpdateAvailable.ts @@ -0,0 +1,14 @@ +import { compareVersions, parseVersion } from '@/utils/system/versionUtils'; +import type { InstallableDepDataLike } from './installablesRegistry'; + +export function isInstallableDepUpdateAvailable(data: InstallableDepDataLike | null): boolean { + if (!data?.installed) return false; + const installed = data.installedVersion; + const latest = data.registry && data.registry.ok ? data.registry.latestVersion : null; + if (!installed || !latest) return false; + const installedParsed = parseVersion(installed); + const latestParsed = parseVersion(latest); + if (!installedParsed || !latestParsed) return false; + return compareVersions(installed, latest) < 0; +} + diff --git a/apps/ui/sources/components/CommandPalette/CommandPaletteInput.tsx b/apps/ui/sources/components/CommandPalette/CommandPaletteInput.tsx index c86351b44..e023afec8 100644 --- a/apps/ui/sources/components/CommandPalette/CommandPaletteInput.tsx +++ b/apps/ui/sources/components/CommandPalette/CommandPaletteInput.tsx @@ -1,20 +1,23 @@ import React from 'react'; -import { View, TextInput, Platform } from 'react-native'; +import { View, Platform } from 'react-native'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; -import { StyleSheet } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { TextInput } from '@/components/ui/text/Text'; + interface CommandPaletteInputProps { value: string; onChangeText: (text: string) => void; onKeyPress?: (key: string) => void; - inputRef?: React.RefObject<TextInput | null>; + inputRef?: React.RefObject<React.ElementRef<typeof TextInput> | null>; placeholder?: string; autoFocus?: boolean; } export function CommandPaletteInput({ value, onChangeText, onKeyPress, inputRef, placeholder, autoFocus = true }: CommandPaletteInputProps) { const styles = stylesheet; + const { theme } = useUnistyles(); const handleKeyDown = React.useCallback((e: any) => { if (Platform.OS === 'web' && onKeyPress) { @@ -37,7 +40,7 @@ export function CommandPaletteInput({ value, onChangeText, onKeyPress, inputRef, value={value} onChangeText={onChangeText} placeholder={placeholder ?? t('commandPalette.placeholder')} - placeholderTextColor="#999" + placeholderTextColor={theme.colors.input.placeholder} autoFocus={autoFocus} autoCorrect={false} autoCapitalize="none" @@ -49,17 +52,17 @@ export function CommandPaletteInput({ value, onChangeText, onKeyPress, inputRef, ); } -const stylesheet = StyleSheet.create(() => ({ +const stylesheet = StyleSheet.create((theme) => ({ container: { borderBottomWidth: 1, - borderBottomColor: 'rgba(0, 0, 0, 0.06)', - backgroundColor: '#FAFAFA', + borderBottomColor: theme.colors.divider, + backgroundColor: theme.colors.surfaceHigh, }, input: { paddingHorizontal: 32, paddingVertical: 24, fontSize: 20, - color: '#000', + color: theme.colors.text, letterSpacing: -0.3, // Remove outline on web ...(Platform.OS === 'web' ? { diff --git a/apps/ui/sources/components/CommandPalette/CommandPaletteItem.tsx b/apps/ui/sources/components/CommandPalette/CommandPaletteItem.tsx index e85719e2e..969bf4e83 100644 --- a/apps/ui/sources/components/CommandPalette/CommandPaletteItem.tsx +++ b/apps/ui/sources/components/CommandPalette/CommandPaletteItem.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { Command } from './types'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { SelectableRow } from '@/components/ui/lists/SelectableRow'; import { Typography } from '@/constants/Typography'; +import { Text } from '@/components/ui/text/Text'; + interface CommandPaletteItemProps { command: Command; @@ -23,19 +25,19 @@ export function CommandPaletteItem({ command, isSelected, onPress, onHover }: Co onPress={onPress} onHover={onHover} left={command.icon ? ( - <View style={{ width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(0, 0, 0, 0.04)', alignItems: 'center', justifyContent: 'center' }}> + <View style={{ width: 32, height: 32, borderRadius: 8, backgroundColor: theme.colors.surfacePressedOverlay, alignItems: 'center', justifyContent: 'center' }}> <Ionicons name={command.icon as any} size={20} - color={isSelected ? '#007AFF' : '#666'} + color={isSelected ? theme.colors.accent.blue : theme.colors.textSecondary} /> </View> ) : null} title={command.title} subtitle={command.subtitle ?? undefined} right={command.shortcut ? ( - <View style={{ paddingHorizontal: 10, paddingVertical: 5, backgroundColor: 'rgba(0, 0, 0, 0.04)', borderRadius: 6 }}> - <Text style={{ ...Typography.mono(), fontSize: 12, color: '#666', fontWeight: '500' }}> + <View style={{ paddingHorizontal: 10, paddingVertical: 5, backgroundColor: theme.colors.surfacePressedOverlay, borderRadius: 6 }}> + <Text style={{ ...Typography.mono(), fontSize: 12, color: theme.colors.textSecondary, fontWeight: '500' }}> {command.shortcut} </Text> </View> diff --git a/apps/ui/sources/components/CommandPalette/CommandPaletteResults.tsx b/apps/ui/sources/components/CommandPalette/CommandPaletteResults.tsx index 1cee6310e..4c5b62889 100644 --- a/apps/ui/sources/components/CommandPalette/CommandPaletteResults.tsx +++ b/apps/ui/sources/components/CommandPalette/CommandPaletteResults.tsx @@ -1,10 +1,12 @@ import React, { useRef, useEffect } from 'react'; -import { View, ScrollView, Text, Platform } from 'react-native'; +import { View, ScrollView, Platform } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { Command, CommandCategory } from './types'; import { CommandPaletteItem } from './CommandPaletteItem'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + interface CommandPaletteResultsProps { categories: CommandCategory[]; @@ -99,7 +101,7 @@ export function CommandPaletteResults({ ); } -const styles = StyleSheet.create({ +const styles = StyleSheet.create((theme) => ({ container: { // Use viewport-based height for better proportions ...(Platform.OS === 'web' ? { @@ -115,7 +117,7 @@ const styles = StyleSheet.create({ }, emptyText: { fontSize: 15, - color: '#999', + color: theme.colors.input.placeholder, letterSpacing: -0.2, }, categoryTitle: { @@ -123,9 +125,9 @@ const styles = StyleSheet.create({ paddingTop: 16, paddingBottom: 8, fontSize: 12, - color: '#999', + color: theme.colors.input.placeholder, textTransform: 'uppercase', letterSpacing: 0.8, fontWeight: '600', }, -}); +})); diff --git a/apps/ui/sources/components/CommandPalette/useCommandPalette.ts b/apps/ui/sources/components/CommandPalette/useCommandPalette.ts index 652ff6980..5accd1e37 100644 --- a/apps/ui/sources/components/CommandPalette/useCommandPalette.ts +++ b/apps/ui/sources/components/CommandPalette/useCommandPalette.ts @@ -1,11 +1,14 @@ import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { TextInput } from 'react-native'; +import type { ElementRef } from 'react'; + import { Command, CommandCategory } from './types'; +import { TextInput } from '@/components/ui/text/Text'; + export function useCommandPalette(commands: Command[], onClose: () => void) { const [searchQuery, setSearchQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); - const inputRef = useRef<TextInput>(null); + const inputRef = useRef<ElementRef<typeof TextInput> | null>(null); // Filter commands based on search query const filteredCategories = useMemo((): CommandCategory[] => { @@ -104,4 +107,4 @@ export function useCommandPalette(commands: Command[], onClose: () => void) { handleKeyPress, setSelectedIndex, }; -} \ No newline at end of file +} diff --git a/apps/ui/sources/components/account/ProviderIdentityItems.test.tsx b/apps/ui/sources/components/account/ProviderIdentityItems.test.tsx index 66753cb21..0790a8060 100644 --- a/apps/ui/sources/components/account/ProviderIdentityItems.test.tsx +++ b/apps/ui/sources/components/account/ProviderIdentityItems.test.tsx @@ -43,11 +43,12 @@ vi.mock('@/sync/api/account/apiIdentity', () => ({ setAccountIdentityShowOnProfile: async () => {}, })); -vi.mock('@/sync/domains/state/storageStore', () => ({ - storage: { +vi.mock('@/sync/domains/state/storageStore', () => { + const storage = { getState: () => ({ profile: profileDefaults }), - }, -})); + }; + return { storage, getStorage: () => storage }; +}); const modalAlert = vi.fn(async () => {}); vi.mock('@/modal', () => ({ diff --git a/apps/ui/sources/components/account/RecoveryKeyReminderBanner.spec.tsx b/apps/ui/sources/components/account/RecoveryKeyReminderBanner.spec.tsx index b53bd4f8c..43c16ade7 100644 --- a/apps/ui/sources/components/account/RecoveryKeyReminderBanner.spec.tsx +++ b/apps/ui/sources/components/account/RecoveryKeyReminderBanner.spec.tsx @@ -18,6 +18,7 @@ vi.mock('react-native', () => ({ OS: 'ios', select: (options: { ios?: unknown; default?: unknown }) => options.ios ?? options.default, }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); vi.mock('@expo/vector-icons', () => ({ diff --git a/apps/ui/sources/components/account/RecoveryKeyReminderBanner.tsx b/apps/ui/sources/components/account/RecoveryKeyReminderBanner.tsx index c9c0b8fe1..48af36c06 100644 --- a/apps/ui/sources/components/account/RecoveryKeyReminderBanner.tsx +++ b/apps/ui/sources/components/account/RecoveryKeyReminderBanner.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Pressable, type GestureResponderEvent } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; @@ -13,6 +14,7 @@ import { SecretKeyBackupModal } from '@/components/account/SecretKeyBackupModal' import { fireAndForget } from '@/utils/system/fireAndForget'; export const RecoveryKeyReminderBanner = React.memo(() => { + const { theme } = useUnistyles(); const auth = useAuth(); const [dismissed, setDismissed] = React.useState<boolean | null>(null); @@ -48,7 +50,7 @@ export const RecoveryKeyReminderBanner = React.memo(() => { <Item title={t('settingsAccount.secretKey')} subtitle={t('settingsAccount.backupDescription')} - icon={<Ionicons name="key-outline" size={28} />} + icon={<Ionicons name="key-outline" size={28} color={theme.colors.textSecondary} />} onPress={() => { Modal.show({ component: SecretKeyBackupModal, @@ -69,7 +71,7 @@ export const RecoveryKeyReminderBanner = React.memo(() => { }} hitSlop={12} > - <Ionicons name="close" size={20} /> + <Ionicons name="close" size={20} color={theme.colors.textSecondary} /> </Pressable> } /> diff --git a/apps/ui/sources/components/account/SecretKeyBackupModal.tsx b/apps/ui/sources/components/account/SecretKeyBackupModal.tsx index 2fa50860b..3df2da785 100644 --- a/apps/ui/sources/components/account/SecretKeyBackupModal.tsx +++ b/apps/ui/sources/components/account/SecretKeyBackupModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; @@ -9,6 +9,8 @@ import { t } from '@/text'; import { Modal } from '@/modal'; import { formatSecretKeyForBackup } from '@/auth/recovery/secretKeyBackup'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme) => ({ modal: { diff --git a/apps/ui/sources/components/automations/editor/AutomationSettingsForm.tsx b/apps/ui/sources/components/automations/editor/AutomationSettingsForm.tsx index c4737c713..d291ccfff 100644 --- a/apps/ui/sources/components/automations/editor/AutomationSettingsForm.tsx +++ b/apps/ui/sources/components/automations/editor/AutomationSettingsForm.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { Platform, TextInput, View } from 'react-native'; +import { Platform, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { Switch } from '@/components/ui/forms/Switch'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; export type AutomationSettingsValue = Readonly<{ enabled: boolean; diff --git a/apps/ui/sources/components/automations/gating/AutomationsGate.tsx b/apps/ui/sources/components/automations/gating/AutomationsGate.tsx index ba1a31c31..c679d3366 100644 --- a/apps/ui/sources/components/automations/gating/AutomationsGate.tsx +++ b/apps/ui/sources/components/automations/gating/AutomationsGate.tsx @@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useAutomationsSupport } from '@/hooks/server/useAutomationsSupport'; import { ItemList } from '@/components/ui/lists/ItemList'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { layout } from '@/components/ui/layout/layout'; const stylesheet = StyleSheet.create((theme) => ({ diff --git a/apps/ui/sources/components/automations/screens/AutomationDetailScreen.tsx b/apps/ui/sources/components/automations/screens/AutomationDetailScreen.tsx index cb88b42a3..d519dc79c 100644 --- a/apps/ui/sources/components/automations/screens/AutomationDetailScreen.tsx +++ b/apps/ui/sources/components/automations/screens/AutomationDetailScreen.tsx @@ -13,7 +13,7 @@ import { ItemList } from '@/components/ui/lists/ItemList'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { Item } from '@/components/ui/lists/Item'; import { Switch } from '@/components/ui/forms/Switch'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { layout } from '@/components/ui/layout/layout'; const stylesheet = StyleSheet.create((theme) => ({ diff --git a/apps/ui/sources/components/automations/screens/AutomationsScreen.tsx b/apps/ui/sources/components/automations/screens/AutomationsScreen.tsx index c1ffd18ce..b236b7c8e 100644 --- a/apps/ui/sources/components/automations/screens/AutomationsScreen.tsx +++ b/apps/ui/sources/components/automations/screens/AutomationsScreen.tsx @@ -7,7 +7,7 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Modal } from '@/modal'; import { useAutomations } from '@/sync/domains/state/storage'; import { sync } from '@/sync/sync'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { layout } from '@/components/ui/layout/layout'; import { ItemList } from '@/components/ui/lists/ItemList'; import { AutomationListGroup } from '@/components/automations/list/AutomationListGroup'; @@ -105,7 +105,7 @@ export function AutomationsScreen() { accessibilityRole="button" accessibilityLabel="Create automation" > - <Ionicons name="add" size={28} color="#FFFFFF" /> + <Ionicons name="add" size={28} color={theme.colors.fab.icon} /> </Pressable> </View> ); diff --git a/apps/ui/sources/components/automations/screens/SessionAutomationCreateScreen.tsx b/apps/ui/sources/components/automations/screens/SessionAutomationCreateScreen.tsx index e1993f886..2411aadbc 100644 --- a/apps/ui/sources/components/automations/screens/SessionAutomationCreateScreen.tsx +++ b/apps/ui/sources/components/automations/screens/SessionAutomationCreateScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Platform, TextInput, View } from 'react-native'; +import { Platform, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useRouter } from 'expo-router'; @@ -7,7 +7,7 @@ import { useRouter } from 'expo-router'; import { ItemList } from '@/components/ui/lists/ItemList'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { Item } from '@/components/ui/lists/Item'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { layout } from '@/components/ui/layout/layout'; import { AutomationSettingsForm, type AutomationSettingsValue } from '@/components/automations/editor/AutomationSettingsForm'; import { Modal } from '@/modal'; @@ -151,7 +151,7 @@ export function SessionAutomationCreateScreen(props: { sessionId: string }) { title="Cannot create automation for this session" subtitle={missingReason} subtitleLines={0} - icon={<Ionicons name="alert-circle-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="alert-circle-outline" size={29} color={theme.colors.warningCritical} />} showChevron={false} /> </ItemGroup> @@ -185,7 +185,7 @@ export function SessionAutomationCreateScreen(props: { sessionId: string }) { <ItemGroup title="Actions"> <Item title="Create automation" - icon={<Ionicons name="checkmark-circle-outline" size={29} color="#34C759" />} + icon={<Ionicons name="checkmark-circle-outline" size={29} color={theme.colors.success} />} onPress={() => void handleCreate()} disabled={!isValid} showChevron={false} diff --git a/apps/ui/sources/components/automations/screens/SessionAutomationsScreen.test.tsx b/apps/ui/sources/components/automations/screens/SessionAutomationsScreen.test.tsx index cdde12422..76d16c91b 100644 --- a/apps/ui/sources/components/automations/screens/SessionAutomationsScreen.test.tsx +++ b/apps/ui/sources/components/automations/screens/SessionAutomationsScreen.test.tsx @@ -33,34 +33,6 @@ vi.mock('@/components/ui/forms/Switch', () => ({ Switch: (props: any) => React.createElement('Switch', props), })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - groupped: { background: '#fff' }, - text: '#111', - textSecondary: '#777', - surfaceHighest: '#eee', - divider: '#ddd', - fab: { background: '#0a84ff' }, - }, - }, - }), - StyleSheet: { - create: (factory: any) => - factory({ - colors: { - groupped: { background: '#fff' }, - text: '#111', - textSecondary: '#777', - surfaceHighest: '#eee', - divider: '#ddd', - fab: { background: '#0a84ff' }, - }, - }), - }, -})); - vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); diff --git a/apps/ui/sources/components/automations/screens/SessionAutomationsScreen.tsx b/apps/ui/sources/components/automations/screens/SessionAutomationsScreen.tsx index f9de7fb33..35558942c 100644 --- a/apps/ui/sources/components/automations/screens/SessionAutomationsScreen.tsx +++ b/apps/ui/sources/components/automations/screens/SessionAutomationsScreen.tsx @@ -7,7 +7,7 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ItemList } from '@/components/ui/lists/ItemList'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { Item } from '@/components/ui/lists/Item'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { layout } from '@/components/ui/layout/layout'; import { Modal } from '@/modal'; import { useAutomations } from '@/sync/domains/state/storage'; @@ -97,7 +97,7 @@ export function SessionAutomationsScreen(props: { sessionId: string }) { <ItemGroup title="Actions"> <Item title="Add automation" - icon={<Ionicons name="add-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="add-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push(`/session/${props.sessionId}/automations/new` as any)} /> </ItemGroup> diff --git a/apps/ui/sources/components/friends/FriendsGate.tsx b/apps/ui/sources/components/friends/FriendsGate.tsx index d68ef2d61..991545c68 100644 --- a/apps/ui/sources/components/friends/FriendsGate.tsx +++ b/apps/ui/sources/components/friends/FriendsGate.tsx @@ -1,14 +1,19 @@ import React from 'react'; -import { View, Text, Pressable, ActivityIndicator } from 'react-native'; +import { View, Pressable, ActivityIndicator } from 'react-native'; +import { useUnistyles } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + export function FriendsGateCentered(props: { title: string; body?: string; children: React.ReactNode }) { + const { theme } = useUnistyles(); + return ( <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }}> - <Text style={{ fontSize: 20, fontWeight: '600', marginBottom: 8 }}> + <Text style={{ fontSize: 20, fontWeight: '600', marginBottom: 8, color: theme.colors.text }}> {props.title} </Text> {props.body ? ( - <Text style={{ textAlign: 'center', opacity: 0.7, marginBottom: 16 }}> + <Text style={{ textAlign: 'center', marginBottom: 16, color: theme.colors.textSecondary }}> {props.body} </Text> ) : null} @@ -28,6 +33,7 @@ export function FriendsProviderConnectControls(props: { connectButtonMarginBottom?: number; notAvailableMarginTop?: number; }) { + const { theme } = useUnistyles(); const [showHint, setShowHint] = React.useState(false); return ( @@ -41,7 +47,7 @@ export function FriendsProviderConnectControls(props: { paddingHorizontal: 16, paddingVertical: 12, borderRadius: 10, - backgroundColor: props.connectButtonColor ?? '#111827', + backgroundColor: props.connectButtonColor ?? theme.colors.button.primary.background, minWidth: 180, alignItems: 'center', marginBottom: props.connectButtonMarginBottom ?? 12, @@ -49,9 +55,9 @@ export function FriendsProviderConnectControls(props: { }} > {props.connecting ? ( - <ActivityIndicator size="small" color="#ffffff" /> + <ActivityIndicator size="small" color={theme.colors.button.primary.tint} /> ) : ( - <Text style={{ color: '#ffffff', fontWeight: '600' }}> + <Text style={{ color: theme.colors.button.primary.tint, fontWeight: '600' }}> {props.connectLabel} </Text> )} @@ -67,13 +73,13 @@ export function FriendsProviderConnectControls(props: { marginTop: props.notAvailableMarginTop ?? 0, }} > - <Text style={{ opacity: 0.8 }}> + <Text style={{ color: theme.colors.textSecondary }}> {props.notAvailableLabel} </Text> </Pressable> {showHint && props.unavailableReason ? ( - <Text style={{ textAlign: 'center', marginTop: 8, opacity: 0.7 }}> + <Text style={{ textAlign: 'center', marginTop: 8, color: theme.colors.textSecondary }}> {props.unavailableReason} </Text> ) : null} diff --git a/apps/ui/sources/components/friends/RequireFriendsIdentityForFriends.test.tsx b/apps/ui/sources/components/friends/RequireFriendsIdentityForFriends.test.tsx index f0abbf730..cdde190c4 100644 --- a/apps/ui/sources/components/friends/RequireFriendsIdentityForFriends.test.tsx +++ b/apps/ui/sources/components/friends/RequireFriendsIdentityForFriends.test.tsx @@ -113,6 +113,11 @@ const hoistedStorage = vi.hoisted(() => { vi.mock('@/sync/domains/state/storageStore', () => ({ storage: hoistedStorage.storage, + getStorage: () => hoistedStorage.storage, +})); + +vi.mock('@/sync/store/hooks', () => ({ + useLocalSetting: () => 1, })); type BaseProps = React.ComponentProps<typeof RequireFriendsIdentityForFriendsBase>; diff --git a/apps/ui/sources/components/friends/RequireFriendsIdentityForFriends.tsx b/apps/ui/sources/components/friends/RequireFriendsIdentityForFriends.tsx index 10f9e386c..1cb1652fb 100644 --- a/apps/ui/sources/components/friends/RequireFriendsIdentityForFriends.tsx +++ b/apps/ui/sources/components/friends/RequireFriendsIdentityForFriends.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Pressable, ActivityIndicator, TextInput, Linking } from 'react-native'; +import { View, Pressable, ActivityIndicator, Linking } from 'react-native'; import { useOAuthProviderConfigured } from '@/hooks/server/useOAuthProviderConfigured'; import { type FriendsUsernameHint } from './resolveFriendsIdentityGate'; import { t } from '@/text'; @@ -14,6 +14,8 @@ import { Modal } from '@/modal'; import { useUnistyles } from 'react-native-unistyles'; import { useFriendsIdentityReadiness } from '@/hooks/server/useFriendsIdentityReadiness'; import { isSafeExternalAuthUrl } from '@/auth/providers/externalAuthUrl'; +import { Text, TextInput } from '@/components/ui/text/Text'; + function translateUsernameHint(hint: FriendsUsernameHint): string { switch (hint.key) { diff --git a/apps/ui/sources/components/friends/UserSearchResult.tsx b/apps/ui/sources/components/friends/UserSearchResult.tsx index fd42949c2..710c17dcb 100644 --- a/apps/ui/sources/components/friends/UserSearchResult.tsx +++ b/apps/ui/sources/components/friends/UserSearchResult.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator, Pressable } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, Pressable } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { UserProfile, getDisplayName } from '@/sync/domains/social/friendTypes'; import { Avatar } from '@/components/ui/avatar/Avatar'; import { t } from '@/text'; import { useRouter } from 'expo-router'; +import { Text } from '@/components/ui/text/Text'; + interface UserSearchResultProps { user: UserProfile; diff --git a/apps/ui/sources/components/machines/DetectedClisList.errorSnapshot.test.ts b/apps/ui/sources/components/machines/DetectedClisList.errorSnapshot.test.ts index d00af58c9..d96cedc18 100644 --- a/apps/ui/sources/components/machines/DetectedClisList.errorSnapshot.test.ts +++ b/apps/ui/sources/components/machines/DetectedClisList.errorSnapshot.test.ts @@ -9,8 +9,10 @@ import { DetectedClisList } from './DetectedClisList'; vi.mock('react-native', () => ({ Platform: { + OS: 'ios', select: <T,>(options: { default?: T; ios?: T }) => options.default ?? options.ios ?? null, }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, Text: 'Text', View: 'View', })); @@ -23,9 +25,16 @@ vi.mock('@/text', () => ({ t: (key: string) => key, })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ theme: { colors: { textSecondary: '#666', status: { connected: '#0a0' } } } }), -})); +vi.mock('react-native-unistyles', () => { + const theme = { colors: { textSecondary: '#666', shadow: { color: '#000', opacity: 0.2 }, status: { connected: '#0a0' } } }; + return { + StyleSheet: { + create: (styles: any) => (typeof styles === 'function' ? styles(theme) : styles), + absoluteFillObject: {}, + }, + useUnistyles: () => ({ theme }), + }; +}); vi.mock('@/components/ui/lists/Item', () => ({ Item: (props: Record<string, unknown>) => React.createElement('Item', props), @@ -36,6 +45,7 @@ vi.mock('@/agents/hooks/useEnabledAgentIds', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: ['claude', 'codex'], getAgentCore: (agentId: string) => { if (agentId === 'claude') { return { displayNameKey: 'agentInput.agent.claude', cli: { detectKey: 'claude' } }; diff --git a/apps/ui/sources/components/machines/DetectedClisList.tsx b/apps/ui/sources/components/machines/DetectedClisList.tsx index e0e03e95d..ea3e8755a 100644 --- a/apps/ui/sources/components/machines/DetectedClisList.tsx +++ b/apps/ui/sources/components/machines/DetectedClisList.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, Text, View } from 'react-native'; +import { Platform, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; import { Item } from '@/components/ui/lists/Item'; @@ -9,6 +9,8 @@ import type { MachineCapabilitiesCacheState } from '@/hooks/server/useMachineCap import type { CapabilityDetectResult, CapabilityId, CliCapabilityData, TmuxCapabilityData } from '@/sync/api/capabilities/capabilitiesProtocol'; import { getAgentCore } from '@/agents/catalog/catalog'; import { useEnabledAgentIds } from '@/agents/hooks/useEnabledAgentIds'; +import { Text } from '@/components/ui/text/Text'; + type Props = { state: MachineCapabilitiesCacheState; diff --git a/apps/ui/sources/components/machines/DetectedClisModal.tsx b/apps/ui/sources/components/machines/DetectedClisModal.tsx index c4e4784bd..099118504 100644 --- a/apps/ui/sources/components/machines/DetectedClisModal.tsx +++ b/apps/ui/sources/components/machines/DetectedClisModal.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, Pressable, ActivityIndicator } from 'react-native'; +import { View, Pressable, ActivityIndicator } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -9,6 +9,8 @@ import { DetectedClisList } from '@/components/machines/DetectedClisList'; import { t } from '@/text'; import type { CustomModalInjectedProps } from '@/modal'; import { CAPABILITIES_REQUEST_NEW_SESSION } from '@/capabilities/requests'; +import { Text } from '@/components/ui/text/Text'; + type Props = CustomModalInjectedProps & { machineId: string; diff --git a/apps/ui/sources/components/machines/InstallableDepInstaller.tsx b/apps/ui/sources/components/machines/InstallableDepInstaller.tsx index 3a2150549..f1fda6b43 100644 --- a/apps/ui/sources/components/machines/InstallableDepInstaller.tsx +++ b/apps/ui/sources/components/machines/InstallableDepInstaller.tsx @@ -9,8 +9,8 @@ import { Modal } from '@/modal'; import { t } from '@/text'; import { useSettingMutable } from '@/sync/domains/state/storage'; import type { CapabilityId } from '@/sync/api/capabilities/capabilitiesProtocol'; -import type { InstallSpecSettingKey } from '@/capabilities/installableDepsRegistry'; -import { compareVersions, parseVersion } from '@/utils/system/versionUtils'; +import type { InstallSpecSettingKey } from '@/capabilities/installablesRegistry'; +import { isInstallableDepUpdateAvailable } from '@/capabilities/installablesUpdateAvailable'; import { useUnistyles } from 'react-native-unistyles'; type InstallableDepData = { @@ -21,17 +21,6 @@ type InstallableDepData = { registry?: { ok: true; latestVersion: string | null } | { ok: false; errorMessage: string }; }; -function computeUpdateAvailable(data: InstallableDepData | null): boolean { - if (!data?.installed) return false; - const installed = data.installedVersion; - const latest = data.registry && data.registry.ok ? data.registry.latestVersion : null; - if (!installed || !latest) return false; - const installedParsed = parseVersion(installed); - const latestParsed = parseVersion(latest); - if (!installedParsed || !latestParsed) return false; - return compareVersions(installed, latest) < 0; -} - export type InstallableDepInstallerProps = { machineId: string; serverId?: string | null; @@ -42,6 +31,7 @@ export type InstallableDepInstallerProps = { depIconName: React.ComponentProps<typeof Ionicons>['name']; depStatus: InstallableDepData | null; capabilitiesStatus: 'idle' | 'loading' | 'loaded' | 'error' | 'not-supported'; + extraItems?: React.ReactNode; installSpecSettingKey: InstallSpecSettingKey; installSpecTitle: string; installSpecDescription: string; @@ -58,7 +48,7 @@ export function InstallableDepInstaller(props: InstallableDepInstallerProps) { if (!props.enabled) return null; - const updateAvailable = computeUpdateAvailable(props.depStatus); + const updateAvailable = isInstallableDepUpdateAvailable(props.depStatus); const subtitle = (() => { if (props.capabilitiesStatus === 'loading') return t('common.loading'); @@ -143,6 +133,8 @@ export function InstallableDepInstaller(props: InstallableDepInstallerProps) { onPress={() => props.refreshRegistry?.()} /> + {props.extraItems} + {props.depStatus?.registry && props.depStatus.registry.ok && props.depStatus.registry.latestVersion && ( <Item title={t('deps.ui.latest')} diff --git a/apps/ui/sources/components/markdown/MarkdownSpansView.linkRel.test.tsx b/apps/ui/sources/components/markdown/MarkdownSpansView.linkRel.test.tsx index dc8b07034..c260dd600 100644 --- a/apps/ui/sources/components/markdown/MarkdownSpansView.linkRel.test.tsx +++ b/apps/ui/sources/components/markdown/MarkdownSpansView.linkRel.test.tsx @@ -8,7 +8,7 @@ vi.mock('expo-router', () => ({ Link: (props: any) => React.createElement('Link', props, props.children), })); -vi.mock('../ui/text/StyledText', () => ({ +vi.mock('../ui/text/Text', () => ({ Text: (props: any) => React.createElement('Text', props, props.children), })); diff --git a/apps/ui/sources/components/markdown/MarkdownSpansView.tsx b/apps/ui/sources/components/markdown/MarkdownSpansView.tsx index f5ae2bbb2..e909c7bbe 100644 --- a/apps/ui/sources/components/markdown/MarkdownSpansView.tsx +++ b/apps/ui/sources/components/markdown/MarkdownSpansView.tsx @@ -1,7 +1,7 @@ import { MarkdownSpan } from './parseMarkdown'; import { Link } from 'expo-router'; import * as React from 'react'; -import { Text } from '../ui/text/StyledText'; +import { Text } from '../ui/text/Text'; export type MarkdownSpansViewProps = { spans: MarkdownSpan[]; diff --git a/apps/ui/sources/components/markdown/MarkdownView.tsx b/apps/ui/sources/components/markdown/MarkdownView.tsx index 1820a94a9..484affed0 100644 --- a/apps/ui/sources/components/markdown/MarkdownView.tsx +++ b/apps/ui/sources/components/markdown/MarkdownView.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { Pressable, ScrollView, View, Platform } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { StyleSheet } from 'react-native-unistyles'; -import { Text } from '../ui/text/StyledText'; +import { Text } from '../ui/text/Text'; import { Typography } from '@/constants/Typography'; import { SimpleSyntaxHighlighter } from '../ui/media/SimpleSyntaxHighlighter'; import { Modal } from '@/modal'; @@ -527,7 +527,8 @@ const style = StyleSheet.create((theme) => ({ overflow: 'hidden', }, tableScrollView: { - flexGrow: 1, + flexGrow: 0, + flexShrink: 0, }, tableContent: { flexDirection: 'row', diff --git a/apps/ui/sources/components/markdown/MermaidRenderer.copy.test.tsx b/apps/ui/sources/components/markdown/MermaidRenderer.copy.test.tsx index 9f624229f..b8706a5c3 100644 --- a/apps/ui/sources/components/markdown/MermaidRenderer.copy.test.tsx +++ b/apps/ui/sources/components/markdown/MermaidRenderer.copy.test.tsx @@ -15,8 +15,12 @@ vi.mock('@/modal', () => ({ }, })); +let lastWebViewHtml: string | null = null; vi.mock('react-native-webview', () => ({ - WebView: () => null, + WebView: (props: any) => { + lastWebViewHtml = props?.source?.html ?? null; + return null; + }, })); describe('MermaidRenderer', () => { @@ -46,4 +50,28 @@ describe('MermaidRenderer', () => { }); } }); + + it('does not interpolate Mermaid source into native WebView HTML', async () => { + const { MermaidRenderer } = await import('./MermaidRenderer'); + + lastWebViewHtml = null; + const payload = 'graph TD\\nA-->B\\n%% </div><img src=x onerror=alert(1)>\\n'; + + let tree: ReturnType<typeof renderer.create> | undefined; + try { + await act(async () => { + tree = renderer.create(<MermaidRenderer content={payload} />); + }); + + expect(typeof lastWebViewHtml).toBe('string'); + expect(lastWebViewHtml).toContain('<div id=\"mermaid-container\"></div>'); + // The source must not be placed as raw HTML content inside the container. + expect(lastWebViewHtml).not.toContain(`<div id=\"mermaid-container\" class=\"mermaid\">`); + expect(lastWebViewHtml).not.toContain(payload); + } finally { + act(() => { + tree?.unmount(); + }); + } + }); }); diff --git a/apps/ui/sources/components/markdown/MermaidRenderer.tsx b/apps/ui/sources/components/markdown/MermaidRenderer.tsx index 9ae42e5d7..1a17524cd 100644 --- a/apps/ui/sources/components/markdown/MermaidRenderer.tsx +++ b/apps/ui/sources/components/markdown/MermaidRenderer.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Platform, Text, Pressable } from 'react-native'; +import { View, Platform, Pressable } from 'react-native'; import { WebView } from 'react-native-webview'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -7,6 +7,8 @@ import { t } from '@/text'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; import { sanitizeRenderedMermaidSvg } from './mermaidSanitize'; +import { Text } from '@/components/ui/text/Text'; + // Style for Web platform const webStyle: any = { @@ -123,6 +125,9 @@ export const MermaidRenderer = React.memo((props: { } // For iOS/Android, use WebView + // Never interpolate Mermaid source into HTML; treat it as data to prevent XSS. + // Escape '<' so sequences like '</script>' can't terminate the script tag when embedding. + const mermaidContentLiteral = React.useMemo(() => JSON.stringify(props.content).replace(/</g, '\\u003c'), [props.content]); const html = ` <!DOCTYPE html> <html> @@ -142,25 +147,44 @@ export const MermaidRenderer = React.memo((props: { align-items: center; width: 100%; } - .mermaid { - text-align: center; - width: 100%; - } - .mermaid svg { + #mermaid-container svg { max-width: 100%; height: auto; } + .error { + color: #ff6b6b; + font-family: monospace; + white-space: pre-wrap; + } </style> </head> <body> - <div id="mermaid-container" class="mermaid"> - ${props.content} - </div> + <div id="mermaid-container"></div> <script> - mermaid.initialize({ - startOnLoad: true, - theme: 'dark' - }); + (async function() { + const content = ${mermaidContentLiteral}; + const container = document.getElementById('mermaid-container'); + + try { + mermaid.initialize({ + startOnLoad: false, + theme: 'dark', + securityLevel: 'strict' + }); + + const { svg } = await mermaid.render('mermaid-diagram', content); + container.innerHTML = svg; + + const height = Math.max(document.body.scrollHeight || 0, container.scrollHeight || 0); + if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { + window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'dimensions', height: height })); + } + } catch (error) { + const raw = (error && error.message) ? String(error.message) : String(error); + const escaped = raw.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + container.innerHTML = '<div class="error">Diagram error: ' + escaped + '</div>'; + } + })(); </script> </body> </html> diff --git a/apps/ui/sources/components/model/ModelPickerOverlay.tsx b/apps/ui/sources/components/model/ModelPickerOverlay.tsx index 730f68995..dd16dbb5f 100644 --- a/apps/ui/sources/components/model/ModelPickerOverlay.tsx +++ b/apps/ui/sources/components/model/ModelPickerOverlay.tsx @@ -1,7 +1,9 @@ import React from 'react'; -import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'; -import { StyleSheet } from 'react-native-unistyles'; +import { ActivityIndicator, Pressable, View } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export type ModelPickerOption = Readonly<{ value: string; @@ -29,8 +31,10 @@ export function ModelPickerOverlay(props: { probe?: ModelPickerProbeState; }) { const styles = stylesheet; + const { theme } = useUnistyles(); const [query, setQuery] = React.useState(''); + const probe = props.probe; const showSearch = props.options.length >= 12; const normalizedQuery = query.trim().toLowerCase(); @@ -46,33 +50,33 @@ export function ModelPickerOverlay(props: { <View style={styles.section}> <View style={styles.titleRow}> <Text style={styles.title}>{props.title}</Text> - {props.probe ? ( - typeof props.probe.onRefresh === 'function' ? ( + {probe ? ( + typeof probe.onRefresh === 'function' ? ( <Pressable - onPress={props.probe.phase === 'idle' ? props.probe.onRefresh : undefined} + onPress={probe.phase === 'idle' ? probe.onRefresh : undefined} style={({ pressed }) => [ styles.refreshIconButton, - pressed && props.probe.phase === 'idle' ? styles.refreshIconButtonPressed : null, - props.probe.phase !== 'idle' ? styles.refreshIconButtonDisabled : null, + pressed && probe.phase === 'idle' ? styles.refreshIconButtonPressed : null, + probe.phase !== 'idle' ? styles.refreshIconButtonDisabled : null, ]} accessibilityRole="button" accessibilityLabel="Refresh models" hitSlop={6} > - {props.probe.phase === 'idle' ? ( + {probe.phase === 'idle' ? ( <Ionicons name="refresh-outline" size={18} style={styles.refreshIcon as any} /> ) : ( <ActivityIndicator size="small" - accessibilityLabel={props.probe.phase === 'loading' ? 'Loading models…' : 'Refreshing models…'} + accessibilityLabel={probe.phase === 'loading' ? 'Loading models…' : 'Refreshing models…'} /> )} </Pressable> - ) : props.probe.phase !== 'idle' ? ( + ) : probe.phase !== 'idle' ? ( <View style={styles.refreshIconButton}> <ActivityIndicator size="small" - accessibilityLabel={props.probe.phase === 'loading' ? 'Loading models…' : 'Refreshing models…'} + accessibilityLabel={probe.phase === 'loading' ? 'Loading models…' : 'Refreshing models…'} /> </View> ) : null @@ -93,7 +97,7 @@ export function ModelPickerOverlay(props: { value={query} onChangeText={setQuery} placeholder="Search models…" - placeholderTextColor="#999" + placeholderTextColor={theme.colors.input.placeholder} autoCorrect={false} autoCapitalize="none" style={styles.searchInput as any} @@ -231,7 +235,7 @@ const stylesheet = StyleSheet.create((theme) => ({ marginRight: 12, }, radioOuterSelected: { - borderColor: theme.colors.button.primary.background, + borderColor: theme.colors.radio.active, }, radioOuterUnselected: { borderColor: theme.colors.divider, @@ -240,7 +244,7 @@ const stylesheet = StyleSheet.create((theme) => ({ width: 8, height: 8, borderRadius: 4, - backgroundColor: theme.colors.button.primary.background, + backgroundColor: theme.colors.radio.active, }, optionLabel: { fontSize: 14, diff --git a/apps/ui/sources/components/navigation/ConnectionStatusControl.tsx b/apps/ui/sources/components/navigation/ConnectionStatusControl.tsx index c40dd2b5d..7f5865332 100644 --- a/apps/ui/sources/components/navigation/ConnectionStatusControl.tsx +++ b/apps/ui/sources/components/navigation/ConnectionStatusControl.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, Pressable } from 'react-native'; +import { View, Pressable } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; import { t } from '@/text'; @@ -21,6 +21,8 @@ import { toServerUrlDisplay } from '@/sync/domains/server/url/serverUrlDisplay'; import { useConnectionTargetActions } from '@/components/navigation/connection/useConnectionTargetActions'; import { ConnectionTargetList } from '@/components/navigation/connection/ConnectionTargetList'; import { promptSignedOutServerSwitchConfirmation } from '@/components/settings/server/modals/ServerSwitchAuthPrompt'; +import { Text } from '@/components/ui/text/Text'; + type Variant = 'sidebar' | 'header'; diff --git a/apps/ui/sources/components/navigation/Header.tsx b/apps/ui/sources/components/navigation/Header.tsx index 2153d7765..1c21a894a 100644 --- a/apps/ui/sources/components/navigation/Header.tsx +++ b/apps/ui/sources/components/navigation/Header.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, Platform, StatusBar, Pressable } from 'react-native'; +import { View, Platform, StatusBar, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { NativeStackHeaderProps } from '@react-navigation/native-stack'; import { Ionicons } from '@expo/vector-icons'; @@ -7,6 +7,8 @@ import { layout } from '../ui/layout/layout'; import { useHeaderHeight, useIsTablet } from '@/utils/platform/responsive'; import { Typography } from '@/constants/Typography'; import { StyleSheet } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + interface HeaderProps { title?: React.ReactNode; diff --git a/apps/ui/sources/components/navigation/HeaderTitleWithAction.tsx b/apps/ui/sources/components/navigation/HeaderTitleWithAction.tsx index 4bf49301e..d665e1b8d 100644 --- a/apps/ui/sources/components/navigation/HeaderTitleWithAction.tsx +++ b/apps/ui/sources/components/navigation/HeaderTitleWithAction.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; +import { Text } from '@/components/ui/text/Text'; + export type HeaderTitleWithActionProps = { title: string; diff --git a/apps/ui/sources/components/navigation/shell/HomeHeader.automationsButton.test.tsx b/apps/ui/sources/components/navigation/shell/HomeHeader.automationsButton.test.tsx index 94894489d..cf5f05c39 100644 --- a/apps/ui/sources/components/navigation/shell/HomeHeader.automationsButton.test.tsx +++ b/apps/ui/sources/components/navigation/shell/HomeHeader.automationsButton.test.tsx @@ -24,41 +24,6 @@ vi.mock('react-native', async (importOriginal) => { }; }); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - header: { tint: '#111' }, - groupped: { background: '#fff' }, - textSecondary: '#777', - status: { - connected: '#0f0', - connecting: '#ff0', - disconnected: '#f00', - error: '#f00', - default: '#777', - }, - }, - }, - }), - StyleSheet: { - create: (factory: any) => factory({ - colors: { - header: { tint: '#111' }, - groupped: { background: '#fff' }, - textSecondary: '#777', - status: { - connected: '#0f0', - connecting: '#ff0', - disconnected: '#f00', - error: '#f00', - default: '#777', - }, - }, - }), - }, -})); - vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); diff --git a/apps/ui/sources/components/navigation/shell/HomeHeader.tsx b/apps/ui/sources/components/navigation/shell/HomeHeader.tsx index 703c46c1f..d9516f2d1 100644 --- a/apps/ui/sources/components/navigation/shell/HomeHeader.tsx +++ b/apps/ui/sources/components/navigation/shell/HomeHeader.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Header } from '@/components/navigation/Header'; import { useSocketStatus } from '@/sync/domains/state/storage'; -import { Platform, Pressable, Text, View } from 'react-native'; +import { Platform, Pressable, View } from 'react-native'; import { Typography } from '@/constants/Typography'; import { StatusDot } from '@/components/ui/status/StatusDot'; import { Ionicons } from '@expo/vector-icons'; @@ -11,6 +11,8 @@ import { Image } from 'expo-image'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { useAutomationsSupport } from '@/hooks/server/useAutomationsSupport'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme, runtime) => ({ headerButton: { @@ -124,6 +126,7 @@ function HeaderRight() { return ( <Pressable + testID="home-header-start-new-session" onPress={() => router.push('/new')} hitSlop={15} style={styles.headerButton} @@ -158,10 +161,9 @@ function HeaderLeft(props: { showAutomations: boolean }) { <View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={styles.logoContainer}> <Image - source={require('@/assets/images/logo-black.png')} + source={theme.dark ? require('@/assets/images/logo-white.png') : require('@/assets/images/logo-black.png')} contentFit="contain" style={[{ width: 24, height: 24 }]} - tintColor={theme.colors.header.tint} /> </View> {props.showAutomations ? ( diff --git a/apps/ui/sources/components/navigation/shell/InboxView.tsx b/apps/ui/sources/components/navigation/shell/InboxView.tsx index c11f7ac06..a134fabe1 100644 --- a/apps/ui/sources/components/navigation/shell/InboxView.tsx +++ b/apps/ui/sources/components/navigation/shell/InboxView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, ScrollView, Pressable, ActivityIndicator } from 'react-native'; +import { View, ScrollView, Pressable, ActivityIndicator } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useAcceptedFriends, useFriendRequests, useRequestedFriends, useFeedItems, useFeedLoaded, useFriendsLoaded, useAllSessions } from '@/sync/domains/state/storage'; import { storage as syncStorage } from '@/sync/domains/state/storageStore'; @@ -20,6 +20,8 @@ import { Image } from 'expo-image'; import { FeedItemCard } from '@/components/inbox/cards/FeedItemCard'; import { RequireFriendsIdentityForFriends } from '@/components/friends/RequireFriendsIdentityForFriends'; import { useFriendsIdentityReadiness } from '@/hooks/server/useFriendsIdentityReadiness'; +import { Text } from '@/components/ui/text/Text'; + const styles = StyleSheet.create((theme) => ({ container: { diff --git a/apps/ui/sources/components/navigation/shell/InboxView.voiceSurface.test.tsx b/apps/ui/sources/components/navigation/shell/InboxView.voiceSurface.test.tsx index bc4c27b85..00014b234 100644 --- a/apps/ui/sources/components/navigation/shell/InboxView.voiceSurface.test.tsx +++ b/apps/ui/sources/components/navigation/shell/InboxView.voiceSurface.test.tsx @@ -16,31 +16,6 @@ vi.mock('react-native', async (importOriginal) => { }; }); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - groupped: { background: '#fff' }, - text: '#111', - textSecondary: '#666', - header: { tint: '#111' }, - divider: '#ddd', - }, - }, - }), - StyleSheet: { - create: (factory: any) => factory({ - colors: { - groupped: { background: '#fff' }, - text: '#111', - textSecondary: '#666', - header: { tint: '#111' }, - divider: '#ddd', - }, - }), - }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); @@ -72,9 +47,11 @@ vi.mock('@/sync/domains/state/storage', () => ({ useAllSessions: () => [], })); -vi.mock('@/sync/domains/state/storageStore', () => ({ - storage: (selector: (state: { profile: { id: string } }) => unknown) => selector({ profile: { id: 'me' } }), -})); +vi.mock('@/sync/domains/state/storageStore', () => { + const storage = (selector: (state: { profile: { id: string }; localSettings: { uiFontScale: number } }) => unknown) => + selector({ profile: { id: 'me' }, localSettings: { uiFontScale: 1 } }); + return { storage, getStorage: () => storage }; +}); vi.mock('@/components/ui/cards/UserCard', () => ({ UserCard: 'UserCard', diff --git a/apps/ui/sources/components/navigation/shell/MainView.primaryPaneGettingStarted.test.tsx b/apps/ui/sources/components/navigation/shell/MainView.primaryPaneGettingStarted.test.tsx index e06fffa30..7c0a1eb5d 100644 --- a/apps/ui/sources/components/navigation/shell/MainView.primaryPaneGettingStarted.test.tsx +++ b/apps/ui/sources/components/navigation/shell/MainView.primaryPaneGettingStarted.test.tsx @@ -23,38 +23,6 @@ vi.mock('react-native', async (importOriginal) => { }; }); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - groupped: { background: '#fff' }, - header: { tint: '#111' }, - text: '#111', - textSecondary: '#777', - status: { connected: '#0f0', connecting: '#ff0', disconnected: '#f00', error: '#f00', default: '#777' }, - surface: '#fff', - button: { primary: { background: '#0a84ff', tint: '#fff' } }, - fab: { background: '#0a84ff' }, - }, - }, - }), - StyleSheet: { - create: (factory: any) => - factory({ - colors: { - groupped: { background: '#fff' }, - header: { tint: '#111' }, - text: '#111', - textSecondary: '#777', - status: { connected: '#0f0', connecting: '#ff0', disconnected: '#f00', error: '#f00', default: '#777' }, - surface: '#fff', - button: { primary: { background: '#0a84ff', tint: '#fff' } }, - fab: { background: '#0a84ff' }, - }, - }), - }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: async () => {} }), })); diff --git a/apps/ui/sources/components/navigation/shell/MainView.sidebarActions.test.tsx b/apps/ui/sources/components/navigation/shell/MainView.sidebarActions.test.tsx index 7c2273ce1..2b7f3f5b1 100644 --- a/apps/ui/sources/components/navigation/shell/MainView.sidebarActions.test.tsx +++ b/apps/ui/sources/components/navigation/shell/MainView.sidebarActions.test.tsx @@ -25,35 +25,6 @@ vi.mock('react-native', async (importOriginal) => { }; }); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - groupped: { background: '#fff' }, - header: { tint: '#111' }, - text: '#111', - textSecondary: '#777', - status: { connected: '#0f0', connecting: '#ff0', disconnected: '#f00', error: '#f00', default: '#777' }, - surface: '#fff', - fab: { background: '#0a84ff' }, - }, - }, - }), - StyleSheet: { - create: (factory: any) => factory({ - colors: { - groupped: { background: '#fff' }, - header: { tint: '#111' }, - text: '#111', - textSecondary: '#777', - status: { connected: '#0f0', connecting: '#ff0', disconnected: '#f00', error: '#f00', default: '#777' }, - surface: '#fff', - fab: { background: '#0a84ff' }, - }, - }), - }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: routerPushSpy }), })); diff --git a/apps/ui/sources/components/navigation/shell/MainView.tsx b/apps/ui/sources/components/navigation/shell/MainView.tsx index 2e37b9a38..252468a70 100644 --- a/apps/ui/sources/components/navigation/shell/MainView.tsx +++ b/apps/ui/sources/components/navigation/shell/MainView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, ActivityIndicator, Text, Pressable } from 'react-native'; +import { View, ActivityIndicator, Pressable } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useFriendRequests, useSocketStatus } from '@/sync/domains/state/storage'; import { useVisibleSessionListViewData } from '@/hooks/session/useVisibleSessionListViewData'; @@ -27,6 +27,8 @@ import { useFriendsIdentityReadiness } from '@/hooks/server/useFriendsIdentityRe import { useAutomationsSupport } from '@/hooks/server/useAutomationsSupport'; import { useFeatureEnabled } from '@/hooks/server/useFeatureEnabled'; import { useTabState } from '@/hooks/ui/useTabState'; +import { Text } from '@/components/ui/text/Text'; + interface MainViewProps { variant: 'phone' | 'sidebar'; @@ -156,6 +158,7 @@ const HeaderRight = React.memo(({ activeTab }: { activeTab: ActiveTabType }) => </Pressable> ) : null} <Pressable + testID="main-header-start-new-session" onPress={() => router.push('/new')} hitSlop={15} style={styles.headerButton} diff --git a/apps/ui/sources/components/navigation/shell/SidebarView.tsx b/apps/ui/sources/components/navigation/shell/SidebarView.tsx index ebe74d8d3..b164635d5 100644 --- a/apps/ui/sources/components/navigation/shell/SidebarView.tsx +++ b/apps/ui/sources/components/navigation/shell/SidebarView.tsx @@ -1,6 +1,6 @@ import { useSocketStatus, useFriendRequests, useSetting, useSyncError } from '@/sync/domains/state/storage'; import * as React from 'react'; -import { Platform, Text, View, Pressable, useWindowDimensions } from 'react-native'; +import { Platform, View, Pressable, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { useHeaderHeight } from '@/utils/platform/responsive'; @@ -23,6 +23,8 @@ import { config } from '@/config'; import { isStackContext } from '@/sync/domains/server/serverContext'; import { isUsingCustomServer } from '@/sync/domains/server/serverConfig'; import { resolveVisibleAppEnvironmentBadge } from '@/sync/runtime/appVariant'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme, runtime) => ({ container: { @@ -396,6 +398,7 @@ export const SidebarView = React.memo(() => { <Pressable onPress={handleNewSession} hitSlop={15} + testID="nav-new-session" accessibilityRole="button" accessibilityLabel={t('newSession.title')} style={styles.iconButton} diff --git a/apps/ui/sources/components/profiles/ProfileRequirementsBadge.tsx b/apps/ui/sources/components/profiles/ProfileRequirementsBadge.tsx index f5380d900..243ec0c9a 100644 --- a/apps/ui/sources/components/profiles/ProfileRequirementsBadge.tsx +++ b/apps/ui/sources/components/profiles/ProfileRequirementsBadge.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; @@ -7,6 +7,8 @@ import type { AIBackendProfile } from '@/sync/domains/settings/settings'; import { t } from '@/text'; import { useProfileEnvRequirements } from '@/hooks/session/useProfileEnvRequirements'; import { hasRequiredSecret } from '@/sync/domains/profiles/profileSecrets'; +import { Text } from '@/components/ui/text/Text'; + export interface ProfileRequirementsBadgeProps { profile: AIBackendProfile; diff --git a/apps/ui/sources/components/profiles/ProfilesList.tsx b/apps/ui/sources/components/profiles/ProfilesList.tsx index 646f6c0b1..db89465fb 100644 --- a/apps/ui/sources/components/profiles/ProfilesList.tsx +++ b/apps/ui/sources/components/profiles/ProfilesList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Platform, useWindowDimensions } from 'react-native'; +import { View, Platform, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; @@ -22,6 +22,8 @@ import { Typography } from '@/constants/Typography'; import { hasRequiredSecret } from '@/sync/domains/profiles/profileSecrets'; import { useSetting } from '@/sync/domains/state/storage'; import { getEnabledAgentIds } from '@/agents/catalog/enabled'; +import { Text } from '@/components/ui/text/Text'; + export interface ProfilesListProps { customProfiles: AIBackendProfile[]; diff --git a/apps/ui/sources/components/profiles/edit/MachinePreviewModal.tsx b/apps/ui/sources/components/profiles/edit/MachinePreviewModal.tsx index 5332e00ac..5086be90c 100644 --- a/apps/ui/sources/components/profiles/edit/MachinePreviewModal.tsx +++ b/apps/ui/sources/components/profiles/edit/MachinePreviewModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Pressable, useWindowDimensions } from 'react-native'; +import { View, Pressable, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -7,6 +7,8 @@ import { t } from '@/text'; import { MachineSelector } from '@/components/sessions/new/components/MachineSelector'; import type { Machine } from '@/sync/domains/state/storageTypes'; import { getActiveServerId } from '@/sync/domains/server/serverProfiles'; +import { Text } from '@/components/ui/text/Text'; + export interface MachinePreviewModalProps { machines: Machine[]; diff --git a/apps/ui/sources/components/profiles/edit/ProfileEditForm.previewMachinePicker.test.ts b/apps/ui/sources/components/profiles/edit/ProfileEditForm.previewMachinePicker.test.ts index 8f5f9fd2a..c56cfc56f 100644 --- a/apps/ui/sources/components/profiles/edit/ProfileEditForm.previewMachinePicker.test.ts +++ b/apps/ui/sources/components/profiles/edit/ProfileEditForm.previewMachinePicker.test.ts @@ -34,6 +34,7 @@ vi.mock('react-native', () => ({ Text: 'Text', TextInput: 'TextInput', Pressable: 'Pressable', + AppState: { addEventListener: () => ({ remove: () => {} }) }, Linking: {}, useWindowDimensions: () => ({ height: 800, width: 400 }), })); @@ -109,6 +110,7 @@ vi.mock('@/agents/hooks/useEnabledAgentIds', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: [], getAgentCore: () => ({ permissions: { modeGroup: 'default' } }), })); diff --git a/apps/ui/sources/components/profiles/edit/ProfileEditForm.tsx b/apps/ui/sources/components/profiles/edit/ProfileEditForm.tsx index 487c110f3..4652df68c 100644 --- a/apps/ui/sources/components/profiles/edit/ProfileEditForm.tsx +++ b/apps/ui/sources/components/profiles/edit/ProfileEditForm.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TextInput, ViewStyle, Linking, Platform, Pressable } from 'react-native'; +import { View, ViewStyle, Linking, Platform, Pressable } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; @@ -29,6 +29,8 @@ import { useEnabledAgentIds } from '@/agents/hooks/useEnabledAgentIds'; import { DEFAULT_AGENT_ID, getAgentCore, type AgentId, type MachineLoginKey } from '@/agents/catalog/catalog'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { MachinePreviewModal } from './MachinePreviewModal'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export interface ProfileEditFormProps { profile: AIBackendProfile; diff --git a/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariableCard.test.ts b/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariableCard.test.ts index 2d0163c3a..c5d9d9cd9 100644 --- a/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariableCard.test.ts +++ b/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariableCard.test.ts @@ -23,61 +23,40 @@ vi.mock('react-native', () => ({ select: (options: { web?: unknown; ios?: unknown; default?: unknown }) => options.web ?? options.ios ?? options.default, }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); vi.mock('@expo/vector-icons', () => ({ Ionicons: (props: Record<string, unknown>) => React.createElement('Ionicons', props), })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - margins: { md: 8 }, - iconSize: { small: 12, large: 16 }, - colors: { - surface: '#fff', - groupped: { sectionTitle: '#666', background: '#fff' }, - shadow: { color: '#000', opacity: 0.1 }, - text: '#000', - textSecondary: '#666', - textDestructive: '#f00', - divider: '#ddd', - input: { background: '#fff', text: '#000', placeholder: '#999' }, - button: { - primary: { background: '#000', tint: '#fff' }, - secondary: { tint: '#000' }, - }, - deleteAction: '#f00', - warning: '#f90', - success: '#0a0', +vi.mock('react-native-unistyles', () => { + const theme = { + margins: { md: 8 }, + iconSize: { small: 12, large: 16 }, + colors: { + surface: '#fff', + groupped: { sectionTitle: '#666', background: '#fff' }, + shadow: { color: '#000', opacity: 0.1 }, + text: '#000', + textSecondary: '#666', + textDestructive: '#f00', + divider: '#ddd', + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, }, + deleteAction: '#f00', + warning: '#f90', + success: '#0a0', }, - }), - StyleSheet: { - create: (factory: (theme: unknown) => unknown) => - factory({ - margins: { md: 8 }, - iconSize: { small: 12, large: 16 }, - colors: { - surface: '#fff', - groupped: { sectionTitle: '#666', background: '#fff' }, - shadow: { color: '#000', opacity: 0.1 }, - text: '#000', - textSecondary: '#666', - textDestructive: '#f00', - divider: '#ddd', - input: { background: '#fff', text: '#000', placeholder: '#999' }, - button: { - primary: { background: '#000', tint: '#fff' }, - secondary: { tint: '#000' }, - }, - deleteAction: '#f00', - warning: '#f90', - success: '#0a0', - }, - }), - }, -})); + }; + return { + useUnistyles: () => ({ theme }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input(theme, {}) : input) }, + }; +}); vi.mock('@/components/ui/forms/Switch', () => ({ Switch: (props: Record<string, unknown>) => React.createElement('Switch', props), diff --git a/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariableCard.tsx b/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariableCard.tsx index 3090cde25..937477915 100644 --- a/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariableCard.tsx +++ b/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariableCard.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TextInput, Pressable, Platform } from 'react-native'; +import { View, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -9,6 +9,8 @@ import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { formatEnvVarTemplate, parseEnvVarTemplate, type EnvVarTemplateOperator } from '@/utils/profiles/envVarTemplate'; import { t } from '@/text'; import type { EnvPreviewSecretsPolicy, PreviewEnvValue } from '@/sync/ops'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export interface EnvironmentVariableCardProps { variable: { name: string; value: string; isSecret?: boolean }; diff --git a/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariablesList.test.ts b/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariablesList.test.ts index 712e25c4b..299f93747 100644 --- a/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariablesList.test.ts +++ b/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariablesList.test.ts @@ -28,6 +28,7 @@ vi.mock('react-native', () => ({ OS: 'web', select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default, }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); type EnvironmentVariablesHookResult = { diff --git a/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariablesList.tsx b/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariablesList.tsx index 2478bf4e2..c2afff4e4 100644 --- a/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariablesList.tsx +++ b/apps/ui/sources/components/profiles/environmentVariables/EnvironmentVariablesList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TextInput, Platform } from 'react-native'; +import { View, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -10,6 +10,8 @@ import { Modal } from '@/modal'; import { t } from '@/text'; import { useEnvironmentVariables } from '@/hooks/server/useEnvironmentVariables'; import { parseEnvVarTemplate } from '@/utils/profiles/envVarTemplate'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export interface EnvironmentVariablesListProps { environmentVariables: Array<{ name: string; value: string; isSecret?: boolean }>; @@ -114,7 +116,7 @@ export function EnvironmentVariablesList({ const [isAddExpanded, setIsAddExpanded] = React.useState(false); const [newVarName, setNewVarName] = React.useState(''); const [newVarValue, setNewVarValue] = React.useState(''); - const nameInputRef = React.useRef<TextInput>(null); + const nameInputRef = React.useRef<React.ElementRef<typeof TextInput> | null>(null); const resetAddDraft = React.useCallback(() => { setNewVarName(''); diff --git a/apps/ui/sources/components/profiles/profileListModel.test.ts b/apps/ui/sources/components/profiles/profileListModel.test.ts index 392431d76..4b412ae36 100644 --- a/apps/ui/sources/components/profiles/profileListModel.test.ts +++ b/apps/ui/sources/components/profiles/profileListModel.test.ts @@ -20,6 +20,7 @@ describe('profileListModel', () => { kimi: 'Kimi', kilo: 'Kilo', pi: 'Pi', + copilot: 'Copilot', }, }; diff --git a/apps/ui/sources/components/secrets/SecretAddModal.tsx b/apps/ui/sources/components/secrets/SecretAddModal.tsx index 40e2f6be5..5eb6ed913 100644 --- a/apps/ui/sources/components/secrets/SecretAddModal.tsx +++ b/apps/ui/sources/components/secrets/SecretAddModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TextInput, Pressable, Platform } from 'react-native'; +import { View, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; @@ -7,6 +7,8 @@ import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { ItemListStatic } from '@/components/ui/lists/ItemList'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export interface SecretAddModalResult { name: string; diff --git a/apps/ui/sources/components/secrets/SecretsList.test.ts b/apps/ui/sources/components/secrets/SecretsList.test.ts index 7aa75fdcc..f6db93ae2 100644 --- a/apps/ui/sources/components/secrets/SecretsList.test.ts +++ b/apps/ui/sources/components/secrets/SecretsList.test.ts @@ -53,6 +53,7 @@ vi.mock('react-native', () => { OS: 'ios', select: <T,>(obj: { ios?: T; default?: T }) => obj.ios ?? obj.default, }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, Pressable, Text, View, diff --git a/apps/ui/sources/components/secrets/SecretsList.tsx b/apps/ui/sources/components/secrets/SecretsList.tsx index 0375f185e..a346cba59 100644 --- a/apps/ui/sources/components/secrets/SecretsList.tsx +++ b/apps/ui/sources/components/secrets/SecretsList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Platform, Text, TextInput, View } from 'react-native'; +import { Platform, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; @@ -12,6 +12,8 @@ import { Modal } from '@/modal'; import type { SavedSecret } from '@/sync/domains/settings/settings'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; +import { Text, TextInput } from '@/components/ui/text/Text'; + function newId(): string { try { @@ -70,7 +72,7 @@ export function SecretsList(props: SecretsListProps) { const [isAddExpanded, setIsAddExpanded] = React.useState(false); const [draftName, setDraftName] = React.useState(''); const [draftValue, setDraftValue] = React.useState(''); - const nameInputRef = React.useRef<TextInput>(null); + const nameInputRef = React.useRef<React.ElementRef<typeof TextInput> | null>(null); const resetAddDraft = React.useCallback(() => { setDraftName(''); diff --git a/apps/ui/sources/components/secrets/requirements/SecretRequirementModal.tsx b/apps/ui/sources/components/secrets/requirements/SecretRequirementModal.tsx index 556d689f0..2c102f17a 100644 --- a/apps/ui/sources/components/secrets/requirements/SecretRequirementModal.tsx +++ b/apps/ui/sources/components/secrets/requirements/SecretRequirementModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Pressable, TextInput, Platform, ScrollView, useWindowDimensions } from 'react-native'; +import { View, Pressable, Platform, ScrollView, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -19,6 +19,8 @@ import { DropdownMenu } from '@/components/ui/forms/dropdown/DropdownMenu'; import { useScrollEdgeFades } from '@/components/ui/scroll/useScrollEdgeFades'; import { ScrollEdgeFades } from '@/components/ui/scroll/ScrollEdgeFades'; import { ScrollEdgeIndicators } from '@/components/ui/scroll/ScrollEdgeIndicators'; +import { Text, TextInput } from '@/components/ui/text/Text'; + const secretRequirementSelectionMemory = new Map<string, 'machine' | 'saved' | 'once'>(); @@ -147,7 +149,7 @@ export function SecretRequirementModal(props: SecretRequirementModalProps) { const initial = props.sessionOnlySecretValueByEnvVarName?.[activeEnvVarName]; return typeof initial === 'string' ? initial : ''; }); - const sessionOnlyInputRef = React.useRef<TextInput>(null); + const sessionOnlyInputRef = React.useRef<React.ElementRef<typeof TextInput> | null>(null); const selectionKey = `${props.profile.id}:${activeEnvVarName}:${props.machineId ?? 'no-machine'}`; const [selectedSource, setSelectedSource] = React.useState<'machine' | 'saved' | 'once' | null>(() => { if (variant === 'defaultForProfile') return 'saved'; diff --git a/apps/ui/sources/components/sessions/SessionNoticeBanner.tsx b/apps/ui/sources/components/sessions/SessionNoticeBanner.tsx index 86ad906b5..38a52f07c 100644 --- a/apps/ui/sources/components/sessions/SessionNoticeBanner.tsx +++ b/apps/ui/sources/components/sessions/SessionNoticeBanner.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; -import { Text, View, type ViewStyle } from 'react-native'; +import { View, ViewStyle } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + export type SessionNoticeBannerProps = { title: string; diff --git a/apps/ui/sources/components/sessions/actions/SessionActionDraftCard.test.tsx b/apps/ui/sources/components/sessions/actions/SessionActionDraftCard.test.tsx index 7b39a1ab9..10cc0940c 100644 --- a/apps/ui/sources/components/sessions/actions/SessionActionDraftCard.test.tsx +++ b/apps/ui/sources/components/sessions/actions/SessionActionDraftCard.test.tsx @@ -15,7 +15,8 @@ vi.mock('react-native', () => ({ Text: 'Text', Pressable: 'Pressable', TextInput: 'TextInput', - Platform: { OS: 'web' }, + Platform: { OS: 'web', select: (options: any) => options?.web ?? options?.default ?? options?.ios ?? null }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, Dimensions: { get: () => ({ width: 1200, height: 800 }), }, @@ -43,9 +44,14 @@ vi.mock('@/agents/hooks/useEnabledAgentIds', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: ['claude'], getAgentCore: () => ({ displayNameKey: 'agent.claude' }), })); +vi.mock('@/sync/store/hooks', () => ({ + useLocalSetting: () => 1, +})); + vi.mock('@/hooks/server/useMachineCapabilitiesCache', () => ({ useMachineCapabilitiesCache: () => ({ state: { status: 'idle' }, refresh: vi.fn() }), })); diff --git a/apps/ui/sources/components/sessions/actions/SessionActionDraftCard.tsx b/apps/ui/sources/components/sessions/actions/SessionActionDraftCard.tsx index 1eaa8302c..683382c3c 100644 --- a/apps/ui/sources/components/sessions/actions/SessionActionDraftCard.tsx +++ b/apps/ui/sources/components/sessions/actions/SessionActionDraftCard.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import { getActionSpec, resolveEffectiveActionInputFields } from '@happier-dev/protocol'; @@ -13,6 +13,8 @@ import { t } from '@/text'; import type { SessionActionDraft } from '@/sync/domains/sessionActions/sessionActionDraftTypes'; import { buildAvailableReviewEngineOptions } from '@/sync/domains/reviews/reviewEngineCatalog'; import { layout } from '@/components/ui/layout/layout'; +import { Text, TextInput } from '@/components/ui/text/Text'; + type EngineOption = Readonly<{ id: string; label: string; disabled?: boolean }>; diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.abortButtonVisibility.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.abortButtonVisibility.test.tsx index 97bb37781..37ce229ff 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.abortButtonVisibility.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.abortButtonVisibility.test.tsx @@ -4,64 +4,26 @@ import renderer, { act } from 'react-test-renderer'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('View', props, props.children), - Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('Text', props, props.children), - Pressable: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('Pressable', props, props.children), - ScrollView: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('ScrollView', props, props.children), - ActivityIndicator: (props: Record<string, unknown>) => React.createElement('ActivityIndicator', props, null), - Platform: { OS: 'ios', select: (v: any) => v.ios }, - useWindowDimensions: () => ({ width: 800, height: 600 }), - Dimensions: { - get: () => ({ width: 800, height: 600, scale: 1, fontScale: 1 }), - }, -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { - create: (styles: any) => { - const theme = { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd' }, - text: '#000', - textSecondary: '#666', - divider: '#ddd', - success: '#0a0', - textDestructive: '#a00', - surfacePressed: '#eee', - }, - }; - return typeof styles === 'function' ? styles(theme) : styles; - }, - }, - useUnistyles: () => ({ - theme: { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd' }, - text: '#000', - textSecondary: '#666', - divider: '#ddd', - success: '#0a0', - textDestructive: '#a00', - surfacePressed: '#eee', - }, +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + View: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('View', props, props.children), + Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Text', props, props.children), + Pressable: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Pressable', props, props.children), + ScrollView: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('ScrollView', props, props.children), + ActivityIndicator: (props: Record<string, unknown>) => React.createElement('ActivityIndicator', props, null), + Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios }, + useWindowDimensions: () => ({ width: 800, height: 600 }), + Dimensions: { + get: () => ({ width: 800, height: 600, scale: 1, fontScale: 1 }), }, - }), -})); + }; +}); vi.mock('@expo/vector-icons', () => ({ Ionicons: (props: Record<string, unknown>) => React.createElement('Ionicons', props, null), @@ -179,6 +141,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.actionBarScroll.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.actionBarScroll.test.tsx new file mode 100644 index 000000000..a2fc8a389 --- /dev/null +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.actionBarScroll.test.tsx @@ -0,0 +1,273 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function flattenStyle(style: any): Record<string, unknown> { + if (!style) return {}; + if (Array.isArray(style)) { + return style.reduce((acc, s) => ({ ...acc, ...flattenStyle(s) }), {}); + } + if (typeof style === 'object') return style; + return {}; +} + +async function mockWebPlatform() { + vi.doMock('react-native', async () => { + const actual = await vi.importActual<any>('react-native'); + return { + ...actual, + Platform: { + ...actual.Platform, + OS: 'web', + select: (v: any) => v?.web ?? v?.default ?? actual.Platform.select(v), + }, + }; + }); +} + +function mockCommonDeps() { + vi.doMock('@/text', () => ({ + t: (key: string) => key, + })); + + vi.doMock('@/components/ui/theme/haptics', () => ({ + hapticsLight: () => { }, + hapticsError: () => { }, + })); + + vi.doMock('@/hooks/server/useFeatureEnabled', () => ({ + useFeatureEnabled: () => false, + })); + + vi.doMock('@/hooks/ui/useKeyboardHeight', () => ({ + useKeyboardHeight: () => 0, + })); + + vi.doMock('@/hooks/session/useUserMessageHistory', () => ({ + useUserMessageHistory: () => ({ reset: () => { }, moveUp: () => { }, moveDown: () => { }, setText: () => { } }), + })); + + vi.doMock('@/components/ui/forms/MultiTextInput', () => ({ + MultiTextInput: (props: Record<string, unknown>) => React.createElement('MultiTextInput', props, null), + })); + + vi.doMock('@/components/ui/forms/Switch', () => ({ + Switch: (props: Record<string, unknown>) => React.createElement('Switch', props, null), + })); + + vi.doMock('@/components/ui/feedback/Shaker', () => ({ + Shaker: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, props.children), + })); + + vi.doMock('@/components/ui/popover', () => ({ + Popover: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, props.children), + })); + + vi.doMock('@/components/ui/overlays/FloatingOverlay', () => ({ + FloatingOverlay: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, props.children), + })); + + vi.doMock('@/components/ui/scroll/ScrollEdgeFades', () => ({ + ScrollEdgeFades: () => null, + })); + + vi.doMock('@/components/ui/scroll/ScrollEdgeIndicators', () => ({ + ScrollEdgeIndicators: (props: Record<string, unknown>) => React.createElement('ScrollEdgeIndicators', props, null), + })); + + vi.doMock('@/components/ui/buttons/PrimaryCircleIconButton', () => ({ + PrimaryCircleIconButton: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('PrimaryCircleIconButton', props, props.children), + })); + + vi.doMock('@/components/ui/lists/ActionListSection', () => ({ + ActionListSection: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('ActionListSection', props, props.children), + })); + + vi.doMock('@/components/ui/status/StatusDot', () => ({ + StatusDot: () => null, + })); + + vi.doMock('@/components/ui/text/Text', () => ({ + Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Text', props, props.children), + })); + + vi.doMock('@/components/tools/shell/permissions/PermissionFooter', () => ({ + PermissionFooter: () => null, + })); + + vi.doMock('@/components/tools/normalization/policy/permissionSummary', () => ({ + formatPermissionRequestSummary: () => '', + })); + + vi.doMock('@/components/sessions/sourceControl/status', () => ({ + SourceControlStatusBadge: () => null, + useHasMeaningfulScmStatus: () => false, + })); + + vi.doMock('@/components/model/ModelPickerOverlay', () => ({ + ModelPickerOverlay: () => null, + })); + + vi.doMock('@/modal', () => ({ + Modal: { alert: vi.fn() }, + })); + + vi.doMock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: ['codex'], + DEFAULT_AGENT_ID: 'codex', + resolveAgentIdFromFlavor: () => null, + getAgentCore: () => ({ displayNameKey: 'agents.codex', toolRendering: { hideUnknownToolsByDefault: false } }), + })); + + vi.doMock('@/sync/domains/models/modelOptions', () => ({ + getModelOptionsForSession: () => [{ value: 'default', label: 'Default' }], + supportsFreeformModelSelectionForSession: () => false, + })); + + vi.doMock('@/sync/domains/models/describeEffectiveModelMode', () => ({ + describeEffectiveModelMode: () => ({ effectiveModelId: 'default' }), + })); + + vi.doMock('@/sync/domains/permissions/permissionModeOptions', () => ({ + getPermissionModeBadgeLabelForAgentType: () => 'Default', + getPermissionModeLabelForAgentType: () => 'Default', + getPermissionModeOptionsForSession: () => [{ value: 'default', label: 'Default' }], + getPermissionModeTitleForAgentType: () => 'Permissions', + })); + + vi.doMock('@/sync/domains/permissions/describeEffectivePermissionMode', () => ({ + describeEffectivePermissionMode: () => ({ effectiveMode: 'default' }), + })); + + vi.doMock('./ResumeChip', () => ({ + ResumeChip: (props: Record<string, unknown>) => React.createElement('ResumeChip', props, null), + formatResumeChipLabel: () => '', + RESUME_CHIP_ICON_NAME: 'play', + RESUME_CHIP_ICON_SIZE: 12, + })); + + vi.doMock('./PathAndResumeRow', () => ({ + PathAndResumeRow: () => null, + })); + + vi.doMock('./components/AgentInputAutocomplete', () => ({ + AgentInputAutocomplete: () => null, + })); +} + +function mockSettings() { + vi.doMock('@/sync/domains/state/storage', () => ({ + useSetting: (key: string) => { + if (key === 'profiles') return []; + if (key === 'agentInputEnterToSend') return true; + if (key === 'agentInputActionBarLayout') return 'scroll'; + if (key === 'agentInputChipDensity') return 'labels'; + if (key === 'sessionPermissionModeApplyTiming') return 'immediate'; + return null; + }, + })); +} + +function mockScrollEdgeFades(params: { canScrollX: boolean; showRight: boolean }) { + vi.doMock('@/components/ui/scroll/useScrollEdgeFades', () => ({ + useScrollEdgeFades: () => ({ + canScrollX: params.canScrollX, + canScrollY: false, + visibility: { left: false, right: params.showRight, top: false, bottom: false }, + onViewportLayout: () => {}, + onContentSizeChange: () => {}, + onScroll: () => {}, + }), + })); +} + +describe('AgentInput (action bar scroll layout)', () => { + it('enables horizontal scrolling on web even when fades cannot measure overflow', async () => { + vi.resetModules(); + vi.clearAllMocks(); + await mockWebPlatform(); + mockCommonDeps(); + mockSettings(); + mockScrollEdgeFades({ canScrollX: false, showRight: false }); + + const { AgentInput } = await import('./AgentInput'); + + let tree: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create( + <AgentInput + value="" + placeholder="Type" + onChangeText={() => {}} + onSend={() => {}} + onPermissionClick={() => {}} + onPathClick={() => {}} + onResumeClick={() => {}} + currentPath="/tmp" + resumeSessionId="s2" + autocompletePrefixes={[]} + autocompleteSuggestions={async () => []} + /> + ); + }); + + const scrollViews = tree!.root.findAll((n: any) => n?.type === 'ScrollView'); + expect(scrollViews).toHaveLength(1); + expect(scrollViews[0]?.props?.horizontal).toBe(true); + expect(scrollViews[0]?.props?.scrollEnabled).toBe(true); + expect(typeof scrollViews[0]?.props?.onWheel).toBe('function'); + expect(typeof scrollViews[0]?.props?.onContentSizeChange).toBe('function'); + + act(() => tree!.unmount()); + }); + + it('measures scroll content via onContentSizeChange and includes right gutter padding', async () => { + vi.resetModules(); + vi.clearAllMocks(); + await mockWebPlatform(); + mockCommonDeps(); + mockSettings(); + mockScrollEdgeFades({ canScrollX: true, showRight: true }); + + const { AgentInput } = await import('./AgentInput'); + + let tree: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create( + <AgentInput + value="" + placeholder="Type" + onChangeText={() => {}} + onSend={() => {}} + onPermissionClick={() => {}} + onPathClick={() => {}} + onResumeClick={() => {}} + currentPath="/tmp" + resumeSessionId="s2" + autocompletePrefixes={[]} + autocompleteSuggestions={async () => []} + /> + ); + }); + + const scrollView = tree!.root.find((n: any) => n?.type === 'ScrollView'); + expect(typeof scrollView?.props?.onContentSizeChange).toBe('function'); + + const contentContainer = scrollView.find((n: any) => n?.type === 'View'); + expect(typeof contentContainer?.props?.onLayout).toBe('undefined'); + + const style = flattenStyle(contentContainer.props.style); + expect(typeof style.paddingRight).toBe('number'); + expect((style.paddingRight as number) > 6).toBe(true); + + act(() => tree!.unmount()); + }); +}); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.dragOverlay.feat.attachments.uploads.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.dragOverlay.feat.attachments.uploads.test.tsx index 98e1fc67a..48d131e1c 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.dragOverlay.feat.attachments.uploads.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.dragOverlay.feat.attachments.uploads.test.tsx @@ -6,8 +6,8 @@ import renderer, { act } from 'react-test-renderer'; let lastMultiTextInputProps: any = null; -vi.mock('react-native', async (importOriginal) => { - const actual = await importOriginal<any>(); +vi.mock('react-native', async () => { + const actual = await import('@/dev/reactNativeStub'); return { ...actual, TurboModuleRegistry: { @@ -26,55 +26,6 @@ vi.mock('react-native', async (importOriginal) => { }; }); -vi.mock('react-native-unistyles', () => { - const theme = { - colors: { - surface: '#fff', - surfaceSelected: '#f2f2f2', - surfacePressed: '#eee', - divider: '#ddd', - text: '#000', - textSecondary: '#666', - groupped: { sectionTitle: '#666', background: '#fff' }, - header: { background: '#fff', tint: '#000' }, - button: { - primary: { background: '#000', tint: '#fff', disabled: '#999' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - shadow: { color: '#000', opacity: 0.2 }, - modal: { border: '#ddd' }, - switch: { track: { inactive: '#ccc', active: '#4ade80' }, thumb: { active: '#fff' } }, - input: { background: '#eee', text: '#000' }, - status: { error: '#ff3b30' }, - box: { error: { background: '#fee', border: '#f99', text: '#900' } }, - radio: { active: '#000', inactive: '#ddd', dot: '#000' }, - success: '#0a0', - textDestructive: '#a00', - permission: { - acceptEdits: '#0a0', - bypass: '#f90', - plan: '#09f', - readOnly: '#999', - safeYolo: '#0af', - yolo: '#f0a', - }, - permissionButton: { - allow: { background: '#0f0' }, - deny: { background: '#f00' }, - allowAll: { background: '#00f' }, - }, - }, - }; - - return { - StyleSheet: { - create: (styles: any) => (typeof styles === 'function' ? styles(theme, {}) : styles), - configure: () => { }, - }, - useUnistyles: () => ({ theme }), - }; -}); - vi.mock('@/components/ui/forms/MultiTextInput', () => ({ MultiTextInput: (props: Record<string, unknown>) => { lastMultiTextInputProps = props; @@ -130,6 +81,10 @@ vi.mock('@/sync/domains/state/storageStore', () => ({ getStorage: () => (selector: any) => selector({ sessionMessages: {} }), })); +vi.mock('@/sync/store/hooks', () => ({ + useLocalSetting: () => 1, +})); + vi.mock('@/agents/catalog/catalog', () => ({ AGENT_IDS: ['codex', 'claude', 'opencode', 'gemini'], DEFAULT_AGENT_ID: 'codex', @@ -174,5 +129,5 @@ describe('AgentInput (attachments drag overlay)', () => { const overlay = tree!.root.findByProps({ testID: 'agent-input-drop-overlay' }); expect(overlay.props.pointerEvents).toBe('none'); - }); + }, 60_000); }); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.historyNavigation.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.historyNavigation.test.tsx index e85dc5905..7fae54f73 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.historyNavigation.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.historyNavigation.test.tsx @@ -16,83 +16,29 @@ const mocks = vi.hoisted(() => ({ onSend: vi.fn(), })); -vi.mock('react-native', () => ({ - View: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('View', props, props.children), - Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('Text', props, props.children), - Pressable: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('Pressable', props, props.children), - ScrollView: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('ScrollView', props, props.children), - ActivityIndicator: (props: Record<string, unknown>) => React.createElement('ActivityIndicator', props, null), - Platform: { OS: 'web', select: (v: any) => v.web ?? v.default ?? null }, - useWindowDimensions: () => ({ width: 900, height: 600 }), - Dimensions: { - get: () => ({ width: 900, height: 600, scale: 1, fontScale: 1 }), - }, -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { - create: (styles: any) => { - const theme = { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff', disabled: '#999' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd', dot: '#000' }, - text: '#000', - textSecondary: '#666', - textLink: '#06f', - divider: '#ddd', - surface: '#fff', - shadow: { color: '#000' }, - success: '#0a0', - textDestructive: '#a00', - surfacePressed: '#eee', - permission: { - bypass: '#000', - plan: '#000', - readOnly: '#000', - safeYolo: '#000', - yolo: '#000', - }, - }, - }; - return typeof styles === 'function' ? styles(theme) : styles; - }, - }, - useUnistyles: () => ({ - theme: { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff', disabled: '#999' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd', dot: '#000' }, - text: '#000', - textSecondary: '#666', - textLink: '#06f', - divider: '#ddd', - surface: '#fff', - shadow: { color: '#000' }, - success: '#0a0', - textDestructive: '#a00', - surfacePressed: '#eee', - permission: { - bypass: '#000', - plan: '#000', - readOnly: '#000', - safeYolo: '#000', - yolo: '#000', - }, - }, +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + View: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('View', props, props.children), + Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Text', props, props.children), + Pressable: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Pressable', props, props.children), + ScrollView: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('ScrollView', props, props.children), + ActivityIndicator: (props: Record<string, unknown>) => React.createElement('ActivityIndicator', props, null), + Platform: { ...rn.Platform, OS: 'web', select: (v: any) => v.web ?? v.default ?? null }, + useWindowDimensions: () => ({ width: 900, height: 600 }), + Dimensions: { + get: () => ({ width: 900, height: 600, scale: 1, fontScale: 1 }), }, - }), + }; +}); + +vi.mock('@/sync/store/hooks', () => ({ + useLocalSetting: () => 1, })); vi.mock('@expo/vector-icons', () => ({ @@ -217,6 +163,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.machineChip.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.machineChip.test.tsx index 8254a126e..3b66b7de1 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.machineChip.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.machineChip.test.tsx @@ -32,52 +32,13 @@ vi.mock('react-native', () => ({ React.createElement('ScrollView', props, props.children), ActivityIndicator: (props: Record<string, unknown>) => React.createElement('ActivityIndicator', props, null), Platform: { OS: 'ios', select: (v: any) => v.ios }, + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, useWindowDimensions: () => ({ width: 800, height: 600 }), Dimensions: { get: () => ({ width: 800, height: 600, scale: 1, fontScale: 1 }), }, })); -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { - create: (styles: any) => { - const theme = { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd' }, - text: '#000', - textSecondary: '#666', - divider: '#ddd', - success: '#0a0', - textDestructive: '#a00', - }, - }; - return typeof styles === 'function' ? styles(theme) : styles; - }, - }, - useUnistyles: () => ({ - theme: { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd' }, - text: '#000', - textSecondary: '#666', - divider: '#ddd', - success: '#0a0', - textDestructive: '#a00', - }, - }, - }), -})); - vi.mock('@expo/vector-icons', () => ({ Ionicons: (props: Record<string, unknown>) => React.createElement('Ionicons', props, null), Octicons: (props: Record<string, unknown>) => React.createElement('Octicons', props, null), @@ -118,6 +79,10 @@ vi.mock('@/sync/domains/state/storageStore', () => ({ getStorage: () => (selector: any) => selector({ sessionMessages: {} }), })); +vi.mock('@/sync/store/hooks', () => ({ + useLocalSetting: () => 1, +})); + vi.mock('@/agents/catalog/catalog', () => ({ AGENT_IDS: ['codex', 'claude', 'opencode', 'gemini'], DEFAULT_AGENT_ID: 'codex', @@ -194,6 +159,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); @@ -279,4 +245,34 @@ describe('AgentInput (machine chip)', () => { const text = collectText(tree?.toJSON()); expect(text.join(' ')).toContain('newSession.selectPathTitle'); }); + + it('exposes a stable testID for the connection status text (UI e2e locator)', async () => { + const { AgentInput } = await import('./AgentInput'); + + let tree: renderer.ReactTestRenderer | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(AgentInput, { + value: '', + placeholder: 'placeholder', + onChangeText: () => {}, + onSend: () => {}, + autocompletePrefixes: [], + autocompleteSuggestions: async () => [], + connectionStatus: { + text: 'online', + color: '#0a0', + dotColor: '#0a0', + isPulsing: false, + }, + }), + ); + }); + + const matches = tree?.root.findAll( + (node) => node.type === 'Text' && node.props?.testID === 'agent-input-connection-status-text' + ); + expect(matches).toHaveLength(1); + expect(collectText(matches?.[0]?.props?.children).join(' ')).toContain('online'); + }); }); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.modelOptionsOverride.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.modelOptionsOverride.test.tsx index 5aebbd966..f61ab1aaa 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.modelOptionsOverride.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.modelOptionsOverride.test.tsx @@ -18,7 +18,7 @@ function nodeContainsExactText(node: renderer.ReactTestInstance, value: string): function findTextNode(tree: renderer.ReactTestRenderer, value: string): renderer.ReactTestInstance | undefined { return tree.root.findAll((node) => ( typeof node.type === 'string' && - node.type === 'Text' && + String(node.type) === 'Text' && nodeContainsExactText(node, value) ))[0]; } @@ -26,7 +26,7 @@ function findTextNode(tree: renderer.ReactTestRenderer, value: string): renderer function findPressableByLabel(tree: renderer.ReactTestRenderer, label: string): renderer.ReactTestInstance | undefined { return tree.root.findAll((node) => ( typeof node.type === 'string' && - node.type === 'Pressable' && + String(node.type) === 'Pressable' && nodeContainsExactText(node, label) ))[0]; } @@ -34,13 +34,16 @@ function findPressableByLabel(tree: renderer.ReactTestRenderer, label: string): function findPressableByAccessibilityLabel(tree: renderer.ReactTestRenderer, label: string): renderer.ReactTestInstance | undefined { return tree.root.findAll((node) => ( typeof node.type === 'string' && - node.type === 'Pressable' && + String(node.type) === 'Pressable' && typeof (node.props as any)?.accessibilityLabel === 'string' && (node.props as any).accessibilityLabel === label ))[0]; } -vi.mock('react-native', () => ({ +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, View: (props: Record<string, unknown> & { children?: React.ReactNode }) => React.createElement('View', props, props.children), Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => @@ -50,52 +53,13 @@ vi.mock('react-native', () => ({ ScrollView: (props: Record<string, unknown> & { children?: React.ReactNode }) => React.createElement('ScrollView', props, props.children), ActivityIndicator: (props: Record<string, unknown>) => React.createElement('ActivityIndicator', props, null), - Platform: { OS: 'ios', select: (v: any) => v.ios }, + Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios }, useWindowDimensions: () => ({ width: 800, height: 600 }), Dimensions: { get: () => ({ width: 800, height: 600, scale: 1, fontScale: 1 }), }, -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { - create: (styles: any) => { - const theme = { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd' }, - text: '#000', - textSecondary: '#666', - divider: '#ddd', - success: '#0a0', - textDestructive: '#a00', - }, - }; - return typeof styles === 'function' ? styles(theme) : styles; - }, - }, - useUnistyles: () => ({ - theme: { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd' }, - text: '#000', - textSecondary: '#666', - divider: '#ddd', - success: '#0a0', - textDestructive: '#a00', - }, - }, - }), -})); + }; +}); vi.mock('@expo/vector-icons', () => ({ Ionicons: (props: Record<string, unknown>) => React.createElement('Ionicons', props, null), @@ -134,7 +98,7 @@ vi.mock('@/sync/domains/state/storage', () => ({ })); vi.mock('@/sync/domains/state/storageStore', () => ({ - getStorage: () => (selector: any) => selector({ sessionMessages: {} }), + getStorage: () => (selector: any) => selector({ sessionMessages: {}, localSettings: { uiFontScale: 1 } }), })); vi.mock('@/agents/catalog/catalog', () => ({ @@ -246,6 +210,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.permissionRequests.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.permissionRequests.test.tsx index 078bfe3d4..d8befaaac 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.permissionRequests.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.permissionRequests.test.tsx @@ -4,7 +4,10 @@ import renderer, { act } from 'react-test-renderer'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, View: (props: Record<string, unknown> & { children?: React.ReactNode }) => React.createElement('View', props, props.children), Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => @@ -14,12 +17,13 @@ vi.mock('react-native', () => ({ ScrollView: (props: Record<string, unknown> & { children?: React.ReactNode }) => React.createElement('ScrollView', props, props.children), ActivityIndicator: (props: Record<string, unknown>) => React.createElement('ActivityIndicator', props, null), - Platform: { OS: 'ios', select: (v: any) => v.ios }, + Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios }, useWindowDimensions: () => ({ width: 800, height: 600 }), Dimensions: { get: () => ({ width: 800, height: 600, scale: 1, fontScale: 1 }), }, -})); + }; +}); vi.mock('react-native-unistyles', () => ({ StyleSheet: { @@ -219,7 +223,15 @@ vi.mock('@/components/ui/lists/ActionListSection', () => ({ })); vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ - useScrollEdgeFades: () => ({ scrollEdgeFadesProps: {}, onScroll: () => {}, onLayout: () => {} }), + useScrollEdgeFades: () => ({ + canScrollX: false, + canScrollY: false, + visibility: { top: false, bottom: false, left: false, right: false }, + onViewportLayout: () => {}, + onContentSizeChange: () => {}, + onScroll: () => {}, + onMomentumScrollEnd: () => {}, + }), })); vi.mock('@/sync/domains/settings/settings', () => ({ diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.sendButtonAccessibility.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.sendButtonAccessibility.test.tsx index 2520f4aad..1c5f2099e 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.sendButtonAccessibility.test.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.sendButtonAccessibility.test.tsx @@ -14,7 +14,10 @@ function findSendPressable(tree: renderer.ReactTestRenderer) { return matches[0]!; } -vi.mock('react-native', () => ({ +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, View: (props: Record<string, unknown> & { children?: React.ReactNode }) => React.createElement('View', props, props.children), Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => @@ -24,54 +27,13 @@ vi.mock('react-native', () => ({ ScrollView: (props: Record<string, unknown> & { children?: React.ReactNode }) => React.createElement('ScrollView', props, props.children), ActivityIndicator: (props: Record<string, unknown>) => React.createElement('ActivityIndicator', props, null), - Platform: { OS: 'web', select: (v: any) => v.web ?? v.default ?? null }, + Platform: { ...rn.Platform, OS: 'web', select: (v: any) => v.web ?? v.default ?? null }, useWindowDimensions: () => ({ width: 800, height: 600 }), Dimensions: { get: () => ({ width: 800, height: 600, scale: 1, fontScale: 1 }), }, -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { - create: (styles: any) => { - const theme = { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff', disabled: '#999' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd', dot: '#000' }, - text: '#000', - textSecondary: '#666', - divider: '#ddd', - success: '#0a0', - textDestructive: '#a00', - surfacePressed: '#eee', - }, - }; - return typeof styles === 'function' ? styles(theme) : styles; - }, - }, - useUnistyles: () => ({ - theme: { - colors: { - input: { background: '#fff' }, - button: { - primary: { background: '#000', tint: '#fff', disabled: '#999' }, - secondary: { tint: '#000', surface: '#fff' }, - }, - radio: { active: '#000', inactive: '#ddd', dot: '#000' }, - text: '#000', - textSecondary: '#666', - divider: '#ddd', - success: '#0a0', - textDestructive: '#a00', - surfacePressed: '#eee', - }, - }, - }), -})); + }; +}); vi.mock('@expo/vector-icons', () => ({ Ionicons: (props: Record<string, unknown>) => React.createElement('Ionicons', props, null), @@ -188,6 +150,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.settingsPopoverProps.test.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.settingsPopoverProps.test.tsx new file mode 100644 index 000000000..9b11ac8e3 --- /dev/null +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.settingsPopoverProps.test.tsx @@ -0,0 +1,318 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + View: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('View', props, props.children), + Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Text', props, props.children), + Pressable: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Pressable', props, props.children), + ScrollView: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('ScrollView', props, props.children), + ActivityIndicator: (props: Record<string, unknown>) => React.createElement('ActivityIndicator', props, null), + Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios }, + useWindowDimensions: () => ({ width: 800, height: 600 }), + Dimensions: { + get: () => ({ width: 800, height: 600, scale: 1, fontScale: 1 }), + }, + }; +}); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { + create: (styles: any) => { + const theme = { + colors: { + input: { background: '#fff' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000', surface: '#fff' }, + }, + radio: { active: '#000', inactive: '#ddd' }, + text: '#000', + textSecondary: '#666', + divider: '#ddd', + success: '#0a0', + textDestructive: '#a00', + surfacePressed: '#eee', + permission: { + acceptEdits: '#0a0', + bypass: '#0a0', + plan: '#0a0', + readOnly: '#0a0', + safeYolo: '#0a0', + yolo: '#0a0', + }, + surfaceHighest: '#fafafa', + }, + }; + return typeof styles === "function" ? styles(theme) : styles; + }, + }, + useUnistyles: () => ({ + theme: { + colors: { + input: { background: '#fff' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000', surface: '#fff' }, + }, + radio: { active: '#000', inactive: '#ddd' }, + text: '#000', + textSecondary: '#666', + divider: '#ddd', + success: '#0a0', + textDestructive: '#a00', + surfacePressed: '#eee', + permission: { + acceptEdits: '#0a0', + bypass: '#0a0', + plan: '#0a0', + readOnly: '#0a0', + safeYolo: '#0a0', + yolo: '#0a0', + }, + surfaceHighest: '#fafafa', + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: (props: Record<string, unknown>) => React.createElement('Ionicons', props, null), + Octicons: (props: Record<string, unknown>) => React.createElement('Octicons', props, null), +})); + +vi.mock('expo-image', () => ({ + Image: (props: Record<string, unknown>) => React.createElement('Image', props, null), +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/components/ui/text/Text', () => ({ + Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Text', props, props.children), +})); + +vi.mock('@/components/ui/layout/layout', () => ({ + layout: { maxWidth: 800, headerMaxWidth: 800 }, +})); + +vi.mock('@/sync/domains/state/storage', () => ({ + useSetting: (key: string) => { + if (key === 'profiles') return []; + if (key === 'agentInputEnterToSend') return true; + if (key === 'agentInputActionBarLayout') return 'collapsed'; + if (key === 'agentInputChipDensity') return 'labels'; + if (key === 'sessionPermissionModeApplyTiming') return 'immediate'; + return null; + }, + useSettings: () => ({ + profiles: [], + agentInputEnterToSend: true, + agentInputActionBarLayout: 'collapsed', + agentInputChipDensity: 'labels', + sessionPermissionModeApplyTiming: 'immediate', + }), + useSessionMessages: () => ({ messages: [], isLoaded: true }), +})); + +vi.mock('@/sync/domains/state/storageStore', () => ({ + getStorage: () => (selector: any) => selector({ sessionMessages: {} }), +})); + +vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: ['codex', 'claude', 'opencode', 'gemini'], + DEFAULT_AGENT_ID: 'codex', + resolveAgentIdFromFlavor: () => null, + getAgentCore: () => ({ displayNameKey: 'agents.codex', toolRendering: { hideUnknownToolsByDefault: false } }), +})); + +vi.mock('@/sync/domains/models/modelOptions', () => ({ + getModelOptionsForSession: () => [{ value: 'default', label: 'Default' }], + supportsFreeformModelSelectionForSession: () => false, +})); + +vi.mock('@/sync/domains/models/describeEffectiveModelMode', () => ({ + describeEffectiveModelMode: () => ({ effectiveModelId: 'default' }), +})); + +vi.mock('@/sync/domains/permissions/permissionModeOptions', () => ({ + getPermissionModeBadgeLabelForAgentType: () => 'Default', + getPermissionModeLabelForAgentType: () => 'Default', + getPermissionModeOptionsForSession: () => [{ value: 'default', label: 'Default' }], + getPermissionModeTitleForAgentType: () => 'Permissions', +})); + +vi.mock('@/sync/domains/permissions/describeEffectivePermissionMode', () => ({ + describeEffectivePermissionMode: () => ({ effectiveMode: 'default' }), +})); + +vi.mock('@/components/ui/forms/MultiTextInput', () => ({ + MultiTextInput: (props: Record<string, unknown>) => React.createElement('MultiTextInput', props, null), +})); + +vi.mock('@/components/ui/buttons/PrimaryCircleIconButton', () => ({ + PrimaryCircleIconButton: () => null, +})); + +vi.mock('@/components/ui/lists/ActionListSection', () => ({ + ActionListSection: () => null, +})); + +vi.mock('@/components/ui/forms/Switch', () => ({ + Switch: (props: Record<string, unknown>) => React.createElement('Switch', props, null), +})); + +vi.mock('@/components/ui/theme/haptics', () => ({ + hapticsLight: () => {}, + hapticsError: () => {}, +})); + +vi.mock('@/components/ui/feedback/Shaker', () => ({ + Shaker: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, props.children), +})); + +vi.mock('@/components/ui/status/StatusDot', () => ({ + StatusDot: () => null, +})); + +vi.mock('@/components/autocomplete/useActiveWord', () => ({ + useActiveWord: () => ({ word: '', start: 0, end: 0 }), +})); + +vi.mock('@/components/autocomplete/useActiveSuggestions', () => ({ + useActiveSuggestions: () => [[], 0, () => {}, () => {}], +})); + +vi.mock('@/components/autocomplete/applySuggestion', () => ({ + applySuggestion: (text: string) => ({ text, cursorPosition: text.length }), +})); + +type CapturedPopoverProps = Record<string, unknown> & { + open: boolean; + anchorRef: React.RefObject<any>; + maxHeightCap?: number; + maxWidthCap?: number; + boundaryRef?: React.RefObject<any> | null; + portal?: { matchAnchorWidth?: boolean }; +}; + +const captured: { last: CapturedPopoverProps | null } = { last: null }; +vi.mock('@/components/ui/popover', () => ({ + Popover: (props: CapturedPopoverProps) => { + captured.last = props; + return null; + }, +})); + +vi.mock('@/components/ui/overlays/FloatingOverlay', () => ({ + FloatingOverlay: () => null, +})); + +vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ + useScrollEdgeFades: () => ({ + canScrollX: false, + visibility: { left: false, right: false }, + onViewportLayout: () => {}, + onContentSizeChange: () => {}, + onScroll: () => {}, + onMomentumScrollEnd: () => {}, + }), +})); + +vi.mock('@/components/ui/scroll/ScrollEdgeFades', () => ({ + ScrollEdgeFades: () => null, +})); + +vi.mock('@/components/ui/scroll/ScrollEdgeIndicators', () => ({ + ScrollEdgeIndicators: () => null, +})); + +vi.mock('@/components/sessions/sourceControl/status', () => ({ + SourceControlStatusBadge: () => null, + useHasMeaningfulScmStatus: () => false, +})); + +vi.mock('@/components/model/ModelPickerOverlay', () => ({ + ModelPickerOverlay: () => null, +})); + +vi.mock('@/hooks/ui/useKeyboardHeight', () => ({ + useKeyboardHeight: () => 0, +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn() }, +})); + +vi.mock('@/sync/acp/sessionModeControl', () => ({ + computeAcpPlanModeControl: () => null, + computeAcpSessionModePickerControl: () => null, +})); + +vi.mock('@/sync/acp/configOptionsControl', () => ({ + computeAcpConfigOptionControls: () => null, +})); + +vi.mock('./components/PermissionModePicker', () => ({ + PermissionModePicker: () => null, +})); + +describe('AgentInput (settings popover props)', () => { + it('anchors the settings popover to the gear button and sizes relative to the agent input', async () => { + const { AgentInput } = await import('./AgentInput'); + + let tree: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create( + <AgentInput + value="" + placeholder="Type" + onChangeText={() => {}} + onSend={() => {}} + autocompletePrefixes={[]} + autocompleteSuggestions={async () => []} + /> + ); + }); + + const gearPressable = tree!.root + .findAll((n: any) => n?.type === 'Pressable') + .find((pressable: any) => { + const gearIcons = pressable.findAll( + (n: any) => n?.type === 'Octicons' && n?.props?.name === 'gear' + ); + return gearIcons.length > 0; + }); + + expect(gearPressable).toBeTruthy(); + expect(typeof gearPressable!.props.onPress).toBe('function'); + + captured.last = null; + act(() => { + gearPressable!.props.onPress(); + }); + + const popoverProps = captured.last; + expect(popoverProps?.open).toBe(true); + expect(popoverProps?.anchorRef).toBe(gearPressable!.props.ref); + expect(popoverProps?.boundaryRef).toBe(null); + expect(popoverProps?.maxHeightCap).toBe(400); + expect(popoverProps?.maxWidthCap).toBe(800); + expect(popoverProps?.portal?.matchAnchorWidth).toBe(false); + + act(() => tree!.unmount()); + }); +}); + diff --git a/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx b/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx index 6c62551ca..f558a0ec1 100644 --- a/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx +++ b/apps/ui/sources/components/sessions/agentInput/AgentInput.tsx @@ -1,6 +1,6 @@ import { Ionicons, Octicons } from '@expo/vector-icons'; import * as React from 'react'; -import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, Pressable, ScrollView } from 'react-native'; +import { View, Platform, useWindowDimensions, ViewStyle, ActivityIndicator, Pressable, ScrollView } from 'react-native'; import { Image } from 'expo-image'; import { layout } from '@/components/ui/layout/layout'; import { MultiTextInput, KeyPressEvent } from '@/components/ui/forms/MultiTextInput'; @@ -38,6 +38,12 @@ import { useSetting } from '@/sync/domains/state/storage'; import { useUserMessageHistory } from '@/hooks/session/useUserMessageHistory'; import { Theme } from '@/theme'; import { t } from '@/text'; + +const ScrollViewWithWheel = ScrollView as unknown as React.ComponentType< + React.ComponentPropsWithRef<typeof ScrollView> & { + onWheel?: any; + } +>; import { Metadata } from '@/sync/domains/state/storageTypes'; import { AIBackendProfile, getProfileEnvironmentVariables } from '@/sync/domains/settings/settings'; import { DEFAULT_AGENT_ID, getAgentCore, resolveAgentIdFromFlavor, type AgentId } from '@/agents/catalog/catalog'; @@ -59,6 +65,11 @@ import { computeAcpConfigOptionControls, type AcpConfigOptionValueId } from '@/s import { PermissionFooter } from '@/components/tools/shell/permissions/PermissionFooter'; import { formatPermissionRequestSummary } from '@/components/tools/normalization/policy/permissionSummary'; import type { PendingPermissionRequest } from '@/utils/sessions/sessionUtils'; +import { Text } from '@/components/ui/text/Text'; +import { attachActionBarMouseDragScroll } from './attachActionBarMouseDragScroll'; + +const ACTION_BAR_SCROLL_END_GUTTER_WIDTH = 24; + export type AgentInputExtraActionChipRenderContext = Readonly<{ chipStyle: (pressed: boolean) => any; @@ -79,6 +90,14 @@ export type AgentInputAttachment = Readonly<{ onRemove?: () => void; }>; +const AGENT_INPUT_TEST_IDS = { + sessionInput: 'session-composer-input', + sessionSend: 'session-composer-send', + newSessionInput: 'new-session-composer-input', + newSessionSend: 'new-session-composer-send', + connectionStatusText: 'agent-input-connection-status-text', +} as const; + interface AgentInputProps { value: string; placeholder: string; @@ -409,7 +428,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ flexDirection: 'row', alignItems: 'center', ...(Platform.OS === 'web' ? { columnGap: 6 } : {}), - paddingRight: 6, + paddingRight: 6 + ACTION_BAR_SCROLL_END_GUTTER_WIDTH, }, actionButtonsFadeLeft: { position: 'absolute', @@ -433,6 +452,10 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ actionButtonsLeftNoFlex: { flex: 0, }, + actionItemWrapper: { + // Non-chip action items (e.g. SCM status) should align with chips on native. + ...(Platform.OS === 'web' ? {} : { marginRight: 6, marginBottom: 6 }), + }, actionChip: { flexDirection: 'row', alignItems: 'center', @@ -518,6 +541,8 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ paddingVertical: 6, justifyContent: 'center', height: 32, + // Keep vertical alignment consistent with `actionChip` on native. + ...(Platform.OS === 'web' ? {} : { marginRight: 6, marginBottom: 6 }), }, actionButtonPressed: { opacity: 0.7, @@ -787,6 +812,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen // Match previous behavior: avoid showing fades for tiny offsets. edgeThreshold: 2, }); + const actionBarScrollRef = React.useRef<any>(null); const permissionModeOptions = React.useMemo(() => { return getPermissionModeOptionsForSession(agentId, props.metadata ?? null); @@ -910,6 +936,127 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen return theme.colors.input.background; }, [theme.colors.input.background]); + const getActionBarScrollNode = React.useCallback(() => { + const raw = actionBarScrollRef.current; + if (!raw) return null; + // RN ScrollView refs often expose getScrollableNode() + return raw.getScrollableNode?.() ?? raw; + }, []); + + const seedActionBarScrollMeasurements = React.useCallback(() => { + if (Platform.OS !== 'web') return; + const node = getActionBarScrollNode() as any; + if (!node) return; + const clientWidth = typeof node.clientWidth === 'number' ? node.clientWidth : null; + const clientHeight = typeof node.clientHeight === 'number' ? node.clientHeight : null; + const scrollWidth = typeof node.scrollWidth === 'number' ? node.scrollWidth : null; + if (clientWidth === null || scrollWidth === null) return; + + // Seed both viewport and content sizes so chevrons/fades can render even before the first scroll event. + actionBarFades.onViewportLayout({ nativeEvent: { layout: { width: clientWidth, height: clientHeight ?? 0 } } }); + actionBarFades.onContentSizeChange( + Math.max(0, scrollWidth - ACTION_BAR_SCROLL_END_GUTTER_WIDTH), + clientHeight ?? 0 + ); + }, [actionBarFades, getActionBarScrollNode]); + + const reportActionBarWebScroll = React.useCallback((nodeOverride?: any) => { + if (Platform.OS !== 'web') return; + const node = (nodeOverride ?? getActionBarScrollNode()) as any; + if (!node) return; + + const clientWidth = typeof node.clientWidth === 'number' ? node.clientWidth : null; + const clientHeight = typeof node.clientHeight === 'number' ? node.clientHeight : 0; + const scrollWidth = typeof node.scrollWidth === 'number' ? node.scrollWidth : null; + const scrollLeft = typeof node.scrollLeft === 'number' ? node.scrollLeft : 0; + if (clientWidth === null || scrollWidth === null) return; + + actionBarFades.onScroll({ + nativeEvent: { + contentOffset: { x: scrollLeft, y: 0 }, + layoutMeasurement: { width: clientWidth, height: clientHeight }, + contentSize: { + width: Math.max(0, scrollWidth - ACTION_BAR_SCROLL_END_GUTTER_WIDTH), + height: clientHeight, + }, + }, + }); + }, [actionBarFades, getActionBarScrollNode]); + + React.useEffect(() => { + if (Platform.OS !== 'web') return; + if (!actionBarShouldScroll) return; + + const requestAnimationFrameSafe: (cb: () => void) => any = + (globalThis as any).requestAnimationFrame?.bind(globalThis) ?? + ((cb: () => void) => setTimeout(cb, 0)); + const cancelAnimationFrameSafe: (id: any) => void = + (globalThis as any).cancelAnimationFrame?.bind(globalThis) ?? + ((id: any) => clearTimeout(id)); + + const rAF = requestAnimationFrameSafe(() => { + seedActionBarScrollMeasurements(); + reportActionBarWebScroll(); + }); + + const node = getActionBarScrollNode(); + if (!node) return () => cancelAnimationFrameSafe(rAF); + + // Keep measurements up-to-date as the viewport changes (resizes, chip density changes, etc). + // Prefer ResizeObserver (more accurate), but fall back to window resize. + const ResizeObserverAny = (globalThis as any).ResizeObserver as (new (cb: () => void) => { observe: (n: any) => void; disconnect: () => void }) | undefined; + if (typeof ResizeObserverAny === 'function') { + const observer = new ResizeObserverAny(() => { + seedActionBarScrollMeasurements(); + reportActionBarWebScroll(); + }); + observer.observe(node as any); + return () => { + cancelAnimationFrameSafe(rAF); + observer.disconnect(); + }; + } + + const onResize = () => { + seedActionBarScrollMeasurements(); + reportActionBarWebScroll(); + }; + const w = (globalThis as any).window as Window | undefined; + w?.addEventListener?.('resize', onResize); + return () => { + cancelAnimationFrameSafe(rAF); + w?.removeEventListener?.('resize', onResize); + }; + }, [actionBarShouldScroll, getActionBarScrollNode, reportActionBarWebScroll, seedActionBarScrollMeasurements]); + + React.useEffect(() => { + if (Platform.OS !== 'web') return; + if (!actionBarShouldScroll) return; + + const requestAnimationFrameSafe: (cb: () => void) => any = + (globalThis as any).requestAnimationFrame?.bind(globalThis) ?? + ((cb: () => void) => setTimeout(cb, 0)); + const cancelAnimationFrameSafe: (id: any) => void = + (globalThis as any).cancelAnimationFrame?.bind(globalThis) ?? + ((id: any) => clearTimeout(id)); + + let cleanup: (() => void) | undefined; + + const rAF = requestAnimationFrameSafe(() => { + const node = getActionBarScrollNode() as any; + if (!node || typeof node.addEventListener !== 'function') return; + cleanup = attachActionBarMouseDragScroll({ + node, + onScroll: () => reportActionBarWebScroll(node), + }); + }); + + return () => { + cancelAnimationFrameSafe(rAF); + cleanup?.(); + }; + }, [actionBarShouldScroll, getActionBarScrollNode, reportActionBarWebScroll]); + // Handle abort button press const handleAbortPress = React.useCallback(async () => { if (!props.onAbort) return; @@ -1126,20 +1273,21 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen )} {/* Settings overlay */} - {showSettings && ( - <Popover - open={showSettings} - anchorRef={settingsAnchorRef} - boundaryRef={null} - placement="top" - gap={8} - maxHeightCap={400} - portal={{ - web: true, - native: true, - matchAnchorWidth: false, - anchorAlign: 'start', - }} + {showSettings && ( + <Popover + open={showSettings} + anchorRef={settingsAnchorRef} + boundaryRef={null} + placement="top" + gap={8} + maxHeightCap={400} + maxWidthCap={layout.maxWidth} + portal={{ + web: true, + native: true, + matchAnchorWidth: false, + anchorAlign: 'start', + }} edgePadding={{ horizontal: Platform.OS === 'web' ? (screenWidth > 700 ? 12 : 16) : 0, vertical: 12, @@ -1153,6 +1301,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen keyboardShouldPersistTaps="always" edgeFades={{ top: true, bottom: true, size: 28 }} edgeIndicators={true} + initialVisibility={{ bottom: true }} > {/* Action shortcuts (collapsed layout) */} {actionMenuActions.length > 0 ? ( @@ -1247,26 +1396,26 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen Mode </Text> {props.acpSessionModeOptionsOverrideProbe && - (props.acpSessionModeOptionsOverrideProbe.phase !== 'idle' || + (props.acpSessionModeOptionsOverrideProbe!.phase !== 'idle' || typeof props.acpSessionModeOptionsOverrideProbe.onRefresh === 'function') ? ( typeof props.acpSessionModeOptionsOverrideProbe.onRefresh === 'function' ? ( <Pressable accessibilityRole="button" accessibilityLabel="Refresh modes" onPress={ - props.acpSessionModeOptionsOverrideProbe.phase === 'idle' + props.acpSessionModeOptionsOverrideProbe!.phase === 'idle' ? props.acpSessionModeOptionsOverrideProbe.onRefresh : undefined } style={({ pressed }) => [ styles.overlayInlineRefreshButton, pressed ? styles.overlayInlineRefreshButtonPressed : null, - props.acpSessionModeOptionsOverrideProbe.phase !== 'idle' + props.acpSessionModeOptionsOverrideProbe!.phase !== 'idle' ? styles.overlayInlineRefreshButtonDisabled : null, ]} > - {props.acpSessionModeOptionsOverrideProbe.phase === 'idle' ? ( + {props.acpSessionModeOptionsOverrideProbe!.phase === 'idle' ? ( <Ionicons name="refresh-outline" size={18} color={theme.colors.textSecondary} /> ) : ( <ActivityIndicator size="small" /> @@ -1554,7 +1703,10 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen size={6} style={styles.statusDot} /> - <Text style={[styles.statusText, { color: props.connectionStatus.color }]}> + <Text + testID={AGENT_INPUT_TEST_IDS.connectionStatusText} + style={[styles.statusText, { color: props.connectionStatus.color }]} + > {props.connectionStatus.text} </Text> </> @@ -1678,6 +1830,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen <View style={[styles.inputContainer, props.minHeight ? { minHeight: props.minHeight } : undefined]}> <MultiTextInput ref={inputRef} + testID={props.sessionId ? AGENT_INPUT_TEST_IDS.sessionInput : AGENT_INPUT_TEST_IDS.newSessionInput} value={props.value} paddingTop={Platform.OS === 'web' ? 10 : 8} paddingBottom={Platform.OS === 'web' ? 10 : 8} @@ -1945,12 +2098,13 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen ) : null; const sourceControlStatusChip = !actionBarIsCollapsed ? ( - <SourceControlStatusButton - key="git" - sessionId={props.sessionId} - onPress={props.onFileViewerPress} - compact={actionBarShouldScroll || !showChipLabels} - /> + <View key="git" style={styles.actionItemWrapper}> + <SourceControlStatusButton + sessionId={props.sessionId} + onPress={props.onFileViewerPress} + compact={actionBarShouldScroll || !showChipLabels} + /> + </View> ) : null; const chips = actionBarIsCollapsed @@ -1972,23 +2126,73 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen // otherwise we never measure content/viewport widths and can't know whether // scrolling is needed (deadlock). if (actionBarShouldScroll) { + const scrollEnabled = Platform.OS === 'web' ? true : canActionBarScroll; + + const handleWheel = (e: any) => { + if (Platform.OS !== 'web') return; + const node = getActionBarScrollNode() as any; + if (!node) return; + const ne = e?.nativeEvent ?? e; + const dx = typeof ne?.deltaX === 'number' ? ne.deltaX : 0; + const dy = typeof ne?.deltaY === 'number' ? ne.deltaY : 0; + // Map vertical wheel to horizontal scrolling (mouse-friendly). + const delta = Math.abs(dy) >= Math.abs(dx) ? dy : dx; + if (!delta) return; + const before = node.scrollLeft ?? 0; + node.scrollLeft = before + delta; + reportActionBarWebScroll(node); + }; + + const handleScrollContentSizeChange = (width: number, height: number) => { + actionBarFades.onContentSizeChange( + Math.max(0, width - ACTION_BAR_SCROLL_END_GUTTER_WIDTH), + height + ); + }; + + const handleScroll = (e: any) => { + if (Platform.OS === 'web') { + reportActionBarWebScroll(); + return; + } + const nativeEvent = e?.nativeEvent; + const contentSizeWidth = nativeEvent?.contentSize?.width; + if (typeof contentSizeWidth !== 'number') { + actionBarFades.onScroll(e); + return; + } + actionBarFades.onScroll({ + ...e, + nativeEvent: { + ...nativeEvent, + contentSize: { + ...nativeEvent.contentSize, + width: Math.max(0, contentSizeWidth - ACTION_BAR_SCROLL_END_GUTTER_WIDTH), + }, + }, + }); + }; + return ( <View style={styles.actionButtonsLeftScroll}> - <ScrollView + <ScrollViewWithWheel + ref={actionBarScrollRef} horizontal showsHorizontalScrollIndicator={false} - scrollEnabled={canActionBarScroll} + scrollEnabled={scrollEnabled} alwaysBounceHorizontal={false} directionalLockEnabled keyboardShouldPersistTaps="handled" - contentContainerStyle={styles.actionButtonsLeftScrollContent as any} + onWheel={handleWheel} onLayout={actionBarFades.onViewportLayout} - onContentSizeChange={actionBarFades.onContentSizeChange} - onScroll={actionBarFades.onScroll} + onContentSizeChange={handleScrollContentSizeChange} + onScroll={handleScroll} scrollEventThrottle={16} > - {chips as any} - </ScrollView> + <View style={styles.actionButtonsLeftScrollContent as any}> + {chips as any} + </View> + </ScrollViewWithWheel> <ScrollEdgeFades color={actionBarFadeColor} size={24} @@ -2018,6 +2222,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen {/* Send/Voice button - aligned with first row */} <PrimaryCircleIconButton + testID={props.sessionId ? AGENT_INPUT_TEST_IDS.sessionSend : AGENT_INPUT_TEST_IDS.newSessionSend} active={hasSendableContent || props.isSending || Boolean(micPressHandler)} loading={props.isSending} disabled={props.disabled || props.isSendDisabled || props.isSending || (!hasSendableContent && !micPressHandler)} diff --git a/apps/ui/sources/components/sessions/agentInput/PathAndResumeRow.test.ts b/apps/ui/sources/components/sessions/agentInput/PathAndResumeRow.test.ts index 220da4435..503a9c8e4 100644 --- a/apps/ui/sources/components/sessions/agentInput/PathAndResumeRow.test.ts +++ b/apps/ui/sources/components/sessions/agentInput/PathAndResumeRow.test.ts @@ -4,14 +4,20 @@ import renderer, { act, type ReactTestInstance } from 'react-test-renderer'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - Pressable: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('Pressable', props, props.children), - Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('Text', props, props.children), - View: (props: Record<string, unknown> & { children?: React.ReactNode }) => - React.createElement('View', props, props.children), -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + AppState: rn.AppState, + Platform: { ...rn.Platform, OS: 'web' }, + Pressable: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Pressable', props, props.children), + Text: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('Text', props, props.children), + View: (props: Record<string, unknown> & { children?: React.ReactNode }) => + React.createElement('View', props, props.children), + }; +}); vi.mock('@expo/vector-icons', () => ({ Ionicons: (props: Record<string, unknown>) => React.createElement('Ionicons', props, null), diff --git a/apps/ui/sources/components/sessions/agentInput/PathAndResumeRow.tsx b/apps/ui/sources/components/sessions/agentInput/PathAndResumeRow.tsx index 73e2e9b65..ab145be4f 100644 --- a/apps/ui/sources/components/sessions/agentInput/PathAndResumeRow.tsx +++ b/apps/ui/sources/components/sessions/agentInput/PathAndResumeRow.tsx @@ -1,7 +1,9 @@ import { Ionicons } from '@expo/vector-icons'; import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { ResumeChip } from './ResumeChip'; +import { Text } from '@/components/ui/text/Text'; + export type PathAndResumeRowStyles = { pathRow: any; diff --git a/apps/ui/sources/components/sessions/agentInput/ResumeChip.tsx b/apps/ui/sources/components/sessions/agentInput/ResumeChip.tsx index 583f993f0..5f5291c5d 100644 --- a/apps/ui/sources/components/sessions/agentInput/ResumeChip.tsx +++ b/apps/ui/sources/components/sessions/agentInput/ResumeChip.tsx @@ -1,7 +1,9 @@ import { Ionicons } from '@expo/vector-icons'; import * as React from 'react'; -import { ActivityIndicator, Pressable, Text } from 'react-native'; +import { ActivityIndicator, Pressable } from 'react-native'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + export const RESUME_CHIP_ICON_NAME = 'refresh-outline' as const; export const RESUME_CHIP_ICON_SIZE = 16 as const; diff --git a/apps/ui/sources/components/sessions/agentInput/actionChips/buildSessionAgentInputActionChips.tsx b/apps/ui/sources/components/sessions/agentInput/actionChips/buildSessionAgentInputActionChips.tsx index f79867ff1..17a62c019 100644 --- a/apps/ui/sources/components/sessions/agentInput/actionChips/buildSessionAgentInputActionChips.tsx +++ b/apps/ui/sources/components/sessions/agentInput/actionChips/buildSessionAgentInputActionChips.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { getActionSpec } from '@happier-dev/protocol'; @@ -8,6 +8,8 @@ import { storage } from '@/sync/domains/state/storage'; import type { AgentInputExtraActionChip } from '@/components/sessions/agentInput/AgentInput'; import { listAgentInputActionChipActionIds } from '@/components/sessions/agentInput/actionChips/listAgentInputActionChipActionIds'; import { buildActionDraftInput } from '@/sync/domains/actions/buildActionDraftInput'; +import { Text } from '@/components/ui/text/Text'; + export function buildSessionAgentInputActionChips(params: Readonly<{ sessionId: string; diff --git a/apps/ui/sources/components/sessions/agentInput/attachActionBarMouseDragScroll.test.ts b/apps/ui/sources/components/sessions/agentInput/attachActionBarMouseDragScroll.test.ts new file mode 100644 index 000000000..1883e6833 --- /dev/null +++ b/apps/ui/sources/components/sessions/agentInput/attachActionBarMouseDragScroll.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; + +import { attachActionBarMouseDragScroll } from './attachActionBarMouseDragScroll'; + +describe('attachActionBarMouseDragScroll', () => { + it('drags horizontally by updating scrollLeft', () => { + const node = document.createElement('div'); + node.scrollLeft = 0; + + const onScroll = vi.fn(); + const cleanup = attachActionBarMouseDragScroll({ node, onScroll }); + + node.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 100, bubbles: true })); + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, bubbles: true })); + + expect(node.scrollLeft).toBe(60); + expect(onScroll).toHaveBeenCalled(); + + window.dispatchEvent(new MouseEvent('mouseup', { button: 0, clientX: 40, bubbles: true })); + cleanup(); + }); + + it('suppresses click after a drag gesture', () => { + const node = document.createElement('div'); + node.scrollLeft = 0; + + const cleanup = attachActionBarMouseDragScroll({ node, onScroll: () => {} }); + + node.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 100, bubbles: true })); + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 30, bubbles: true })); + window.dispatchEvent(new MouseEvent('mouseup', { button: 0, clientX: 30, bubbles: true })); + + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + node.dispatchEvent(click); + expect(click.defaultPrevented).toBe(true); + + // Next click is not suppressed (didDrag reset). + const click2 = new MouseEvent('click', { bubbles: true, cancelable: true }); + node.dispatchEvent(click2); + expect(click2.defaultPrevented).toBe(false); + + cleanup(); + }); + + it('does not suppress click after a non-drag click', () => { + const node = document.createElement('div'); + node.scrollLeft = 0; + + const cleanup = attachActionBarMouseDragScroll({ node, onScroll: () => {} }); + + node.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 100, bubbles: true })); + // No movement above threshold. + window.dispatchEvent(new MouseEvent('mouseup', { button: 0, clientX: 100, bubbles: true })); + + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + node.dispatchEvent(click); + expect(click.defaultPrevented).toBe(false); + + cleanup(); + }); +}); diff --git a/apps/ui/sources/components/sessions/agentInput/attachActionBarMouseDragScroll.ts b/apps/ui/sources/components/sessions/agentInput/attachActionBarMouseDragScroll.ts new file mode 100644 index 000000000..aa9a8cb9a --- /dev/null +++ b/apps/ui/sources/components/sessions/agentInput/attachActionBarMouseDragScroll.ts @@ -0,0 +1,75 @@ +export type AttachActionBarMouseDragScrollParams = Readonly<{ + node: HTMLElement; + onScroll: () => void; + /** + * Pixel threshold before we treat the gesture as a drag (vs. click). + * Defaults to 3. + */ + dragThresholdPx?: number; +}>; + +export function attachActionBarMouseDragScroll(params: AttachActionBarMouseDragScrollParams) { + const dragThresholdPx = params.dragThresholdPx ?? 3; + const node = params.node; + const w = (globalThis as any).window as Window | undefined; + + let isDown = false; + let startX = 0; + let startScrollLeft = 0; + let didDrag = false; + + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; + isDown = true; + startX = e.clientX; + startScrollLeft = node.scrollLeft ?? 0; + didDrag = false; + try { + node.style.cursor = 'grabbing'; + } catch {} + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isDown) return; + const dx = e.clientX - startX; + if (Math.abs(dx) > dragThresholdPx) didDrag = true; + + node.scrollLeft = (startScrollLeft ?? 0) - dx; + params.onScroll(); + + if (didDrag) { + e.preventDefault(); + } + }; + + const onMouseUp = () => { + isDown = false; + try { + node.style.cursor = 'grab'; + } catch {} + }; + + const onClickCapture = (e: MouseEvent) => { + if (!didDrag) return; + e.preventDefault(); + e.stopPropagation(); + didDrag = false; + }; + + try { + node.style.cursor = 'grab'; + } catch {} + + node.addEventListener('mousedown', onMouseDown, { capture: true }); + // Listen on window so the drag continues even if the cursor leaves the node. + w?.addEventListener?.('mousemove', onMouseMove, { capture: true } as any); + w?.addEventListener?.('mouseup', onMouseUp, { capture: true } as any); + node.addEventListener('click', onClickCapture, { capture: true }); + + return () => { + node.removeEventListener('mousedown', onMouseDown, { capture: true } as any); + w?.removeEventListener?.('mousemove', onMouseMove, { capture: true } as any); + w?.removeEventListener?.('mouseup', onMouseUp, { capture: true } as any); + node.removeEventListener('click', onClickCapture, { capture: true } as any); + }; +} diff --git a/apps/ui/sources/components/sessions/agentInput/components/AgentInputSuggestionView.tsx b/apps/ui/sources/components/sessions/agentInput/components/AgentInputSuggestionView.tsx index 5bb6b5a46..d677fddd0 100644 --- a/apps/ui/sources/components/sessions/agentInput/components/AgentInputSuggestionView.tsx +++ b/apps/ui/sources/components/sessions/agentInput/components/AgentInputSuggestionView.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + interface CommandSuggestionProps { command: string; diff --git a/apps/ui/sources/components/sessions/agentInput/components/PermissionModePicker.tsx b/apps/ui/sources/components/sessions/agentInput/components/PermissionModePicker.tsx index e9e4bf55b..9aa3dd806 100644 --- a/apps/ui/sources/components/sessions/agentInput/components/PermissionModePicker.tsx +++ b/apps/ui/sources/components/sessions/agentInput/components/PermissionModePicker.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import type { PermissionMode } from '@/sync/domains/permissions/permissionTypes'; import type { EffectivePermissionModeDescription } from '@/sync/domains/permissions/describeEffectivePermissionMode'; +import { Text } from '@/components/ui/text/Text'; + export type PermissionModePickerOption = Readonly<{ value: PermissionMode; diff --git a/apps/ui/sources/components/sessions/delegations/messages/DelegateOutputMessageCard.tsx b/apps/ui/sources/components/sessions/delegations/messages/DelegateOutputMessageCard.tsx index 2375edd7a..a75a38dab 100644 --- a/apps/ui/sources/components/sessions/delegations/messages/DelegateOutputMessageCard.tsx +++ b/apps/ui/sources/components/sessions/delegations/messages/DelegateOutputMessageCard.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { DelegateOutputV1 } from '@happier-dev/protocol'; +import { Text } from '@/components/ui/text/Text'; + export function DelegateOutputMessageCard(props: Readonly<{ payload: DelegateOutputV1 }>) { const deliverables = props.payload.deliverables ?? []; diff --git a/apps/ui/sources/components/sessions/files/FilesToolbar.test.tsx b/apps/ui/sources/components/sessions/files/FilesToolbar.test.tsx index 7e84e0de9..4a8236af8 100644 --- a/apps/ui/sources/components/sessions/files/FilesToolbar.test.tsx +++ b/apps/ui/sources/components/sessions/files/FilesToolbar.test.tsx @@ -16,8 +16,9 @@ vi.mock('@expo/vector-icons', () => ({ Octicons: 'Octicons', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/text', () => ({ diff --git a/apps/ui/sources/components/sessions/files/FilesToolbar.tsx b/apps/ui/sources/components/sessions/files/FilesToolbar.tsx index 8211ae7dc..61317f686 100644 --- a/apps/ui/sources/components/sessions/files/FilesToolbar.tsx +++ b/apps/ui/sources/components/sessions/files/FilesToolbar.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { Platform, Pressable, TextInput, View } from 'react-native'; +import { Platform, Pressable, View } from 'react-native'; import { Octicons } from '@expo/vector-icons'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import type { ChangedFilesPresentation, ChangedFilesViewMode } from '@/scm/scmAttribution'; diff --git a/apps/ui/sources/components/sessions/files/SourceControlBranchSummary.test.tsx b/apps/ui/sources/components/sessions/files/SourceControlBranchSummary.test.tsx index e1afabf01..8a1b17b25 100644 --- a/apps/ui/sources/components/sessions/files/SourceControlBranchSummary.test.tsx +++ b/apps/ui/sources/components/sessions/files/SourceControlBranchSummary.test.tsx @@ -14,8 +14,9 @@ vi.mock('@expo/vector-icons', () => ({ Octicons: 'Octicons', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/text', () => ({ diff --git a/apps/ui/sources/components/sessions/files/SourceControlBranchSummary.tsx b/apps/ui/sources/components/sessions/files/SourceControlBranchSummary.tsx index 058d92687..86187d3aa 100644 --- a/apps/ui/sources/components/sessions/files/SourceControlBranchSummary.tsx +++ b/apps/ui/sources/components/sessions/files/SourceControlBranchSummary.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Platform, View } from 'react-native'; import { Octicons } from '@expo/vector-icons'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import type { ScmStatusFiles } from '@/scm/scmStatusFiles'; diff --git a/apps/ui/sources/components/sessions/files/SourceControlOperationsHistorySection.test.tsx b/apps/ui/sources/components/sessions/files/SourceControlOperationsHistorySection.test.tsx index e07e6fe82..1674a0d99 100644 --- a/apps/ui/sources/components/sessions/files/SourceControlOperationsHistorySection.test.tsx +++ b/apps/ui/sources/components/sessions/files/SourceControlOperationsHistorySection.test.tsx @@ -14,8 +14,9 @@ vi.mock('@expo/vector-icons', () => ({ Octicons: 'Octicons', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/components/sessions/files/SourceControlOperationsHistorySection.tsx b/apps/ui/sources/components/sessions/files/SourceControlOperationsHistorySection.tsx index dcd233262..d6d369260 100644 --- a/apps/ui/sources/components/sessions/files/SourceControlOperationsHistorySection.tsx +++ b/apps/ui/sources/components/sessions/files/SourceControlOperationsHistorySection.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { ActivityIndicator, Pressable, View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { Octicons } from '@expo/vector-icons'; import type { ScmLogEntry } from '@happier-dev/protocol'; diff --git a/apps/ui/sources/components/sessions/files/SourceControlOperationsLogSection.tsx b/apps/ui/sources/components/sessions/files/SourceControlOperationsLogSection.tsx index cbf2bd2f9..ab6eaae9e 100644 --- a/apps/ui/sources/components/sessions/files/SourceControlOperationsLogSection.tsx +++ b/apps/ui/sources/components/sessions/files/SourceControlOperationsLogSection.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Pressable, View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import type { ScmProjectOperationLogEntry } from '@/sync/runtime/orchestration/projectManager'; diff --git a/apps/ui/sources/components/sessions/files/SourceControlOperationsPanel.test.tsx b/apps/ui/sources/components/sessions/files/SourceControlOperationsPanel.test.tsx index 8727c0416..2d29fb037 100644 --- a/apps/ui/sources/components/sessions/files/SourceControlOperationsPanel.test.tsx +++ b/apps/ui/sources/components/sessions/files/SourceControlOperationsPanel.test.tsx @@ -13,8 +13,9 @@ vi.mock('react-native', () => ({ Platform: { select: (value: any) => value?.default ?? null }, })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/components/sessions/files/SourceControlOperationsPanel.tsx b/apps/ui/sources/components/sessions/files/SourceControlOperationsPanel.tsx index b2fc08c34..7d252078b 100644 --- a/apps/ui/sources/components/sessions/files/SourceControlOperationsPanel.tsx +++ b/apps/ui/sources/components/sessions/files/SourceControlOperationsPanel.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Platform, Pressable, View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { SourceControlOperationsHistorySection } from '@/components/sessions/files/SourceControlOperationsHistorySection'; import { SourceControlOperationsLogSection } from '@/components/sessions/files/SourceControlOperationsLogSection'; diff --git a/apps/ui/sources/components/sessions/files/changedFiles/ChangedFilesSectionHeader.tsx b/apps/ui/sources/components/sessions/files/changedFiles/ChangedFilesSectionHeader.tsx index 76b51e791..8647e728d 100644 --- a/apps/ui/sources/components/sessions/files/changedFiles/ChangedFilesSectionHeader.tsx +++ b/apps/ui/sources/components/sessions/files/changedFiles/ChangedFilesSectionHeader.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Platform, View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; export function ChangedFilesSectionHeader(props: { diff --git a/apps/ui/sources/components/sessions/files/commit/ScmCommitMessageEditorModal.test.tsx b/apps/ui/sources/components/sessions/files/commit/ScmCommitMessageEditorModal.test.tsx index 1fac01a64..c04e46736 100644 --- a/apps/ui/sources/components/sessions/files/commit/ScmCommitMessageEditorModal.test.tsx +++ b/apps/ui/sources/components/sessions/files/commit/ScmCommitMessageEditorModal.test.tsx @@ -4,12 +4,13 @@ import renderer, { act } from 'react-test-renderer'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TextInput: 'TextInput', - Pressable: 'Pressable', -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + Platform: { ...rn.Platform, OS: 'ios', select: (spec: any) => spec?.ios ?? spec?.default ?? spec?.web ?? spec?.android }, + }; +}); vi.mock('react-native-unistyles', () => ({ useUnistyles: () => ({ diff --git a/apps/ui/sources/components/sessions/files/commit/ScmCommitMessageEditorModal.tsx b/apps/ui/sources/components/sessions/files/commit/ScmCommitMessageEditorModal.tsx index 4d63f021a..120caa72c 100644 --- a/apps/ui/sources/components/sessions/files/commit/ScmCommitMessageEditorModal.tsx +++ b/apps/ui/sources/components/sessions/files/commit/ScmCommitMessageEditorModal.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export type ScmCommitMessageGenerateResult = | { ok: true; message: string } diff --git a/apps/ui/sources/components/sessions/files/content/ChangedFilesList.test.tsx b/apps/ui/sources/components/sessions/files/content/ChangedFilesList.test.tsx index 806a6b9bd..16ddecc7f 100644 --- a/apps/ui/sources/components/sessions/files/content/ChangedFilesList.test.tsx +++ b/apps/ui/sources/components/sessions/files/content/ChangedFilesList.test.tsx @@ -13,8 +13,9 @@ vi.mock('@expo/vector-icons', () => ({ Octicons: 'Octicons', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/components/ui/media/FileIcon', () => ({ diff --git a/apps/ui/sources/components/sessions/files/content/ChangedFilesList.tsx b/apps/ui/sources/components/sessions/files/content/ChangedFilesList.tsx index 7baf8d940..6b7534cd7 100644 --- a/apps/ui/sources/components/sessions/files/content/ChangedFilesList.tsx +++ b/apps/ui/sources/components/sessions/files/content/ChangedFilesList.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Platform, View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Item } from '@/components/ui/lists/Item'; import { Typography } from '@/constants/Typography'; import type { SessionAttributedFile, SessionAttributionReliability, ChangedFilesViewMode } from '@/scm/scmAttribution'; diff --git a/apps/ui/sources/components/sessions/files/content/ChangedFilesReview.test.tsx b/apps/ui/sources/components/sessions/files/content/ChangedFilesReview.test.tsx index 534ecfec7..f5b939a07 100644 --- a/apps/ui/sources/components/sessions/files/content/ChangedFilesReview.test.tsx +++ b/apps/ui/sources/components/sessions/files/content/ChangedFilesReview.test.tsx @@ -20,8 +20,9 @@ vi.mock('@expo/vector-icons', () => ({ Octicons: 'Octicons', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/components/ui/media/FileIcon', () => ({ diff --git a/apps/ui/sources/components/sessions/files/content/ChangedFilesReview.tsx b/apps/ui/sources/components/sessions/files/content/ChangedFilesReview.tsx index 1e4fe7dc9..ad3cf07c3 100644 --- a/apps/ui/sources/components/sessions/files/content/ChangedFilesReview.tsx +++ b/apps/ui/sources/components/sessions/files/content/ChangedFilesReview.tsx @@ -3,7 +3,7 @@ import { ActivityIndicator, Platform, Pressable, View, useWindowDimensions } fro import { Octicons } from '@expo/vector-icons'; import { Item } from '@/components/ui/lists/Item'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import type { SessionAttributedFile, SessionAttributionReliability, ChangedFilesViewMode } from '@/scm/scmAttribution'; import type { ScmWorkingSnapshot } from '@/sync/domains/state/storageTypes'; diff --git a/apps/ui/sources/components/sessions/files/content/RepositoryTreeList.test.tsx b/apps/ui/sources/components/sessions/files/content/RepositoryTreeList.test.tsx index 4a4e54477..3fdfadac3 100644 --- a/apps/ui/sources/components/sessions/files/content/RepositoryTreeList.test.tsx +++ b/apps/ui/sources/components/sessions/files/content/RepositoryTreeList.test.tsx @@ -26,8 +26,9 @@ vi.mock('@expo/vector-icons', () => ({ Octicons: 'Octicons', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/components/ui/media/FileIcon', () => ({ diff --git a/apps/ui/sources/components/sessions/files/content/RepositoryTreeList.tsx b/apps/ui/sources/components/sessions/files/content/RepositoryTreeList.tsx index 01db72604..05dfe9472 100644 --- a/apps/ui/sources/components/sessions/files/content/RepositoryTreeList.tsx +++ b/apps/ui/sources/components/sessions/files/content/RepositoryTreeList.tsx @@ -4,7 +4,7 @@ import { Octicons } from '@expo/vector-icons'; import { Item } from '@/components/ui/lists/Item'; import { FileIcon } from '@/components/ui/media/FileIcon'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { useRepositoryTreeBrowser } from '@/hooks/session/files/useRepositoryTreeBrowser'; import { SourceControlUnavailableState } from '@/components/sessions/sourceControl/states'; diff --git a/apps/ui/sources/components/sessions/files/content/SearchResultsList.test.tsx b/apps/ui/sources/components/sessions/files/content/SearchResultsList.test.tsx index 5b075d26f..f56584975 100644 --- a/apps/ui/sources/components/sessions/files/content/SearchResultsList.test.tsx +++ b/apps/ui/sources/components/sessions/files/content/SearchResultsList.test.tsx @@ -14,8 +14,9 @@ vi.mock('@expo/vector-icons', () => ({ Octicons: 'Octicons', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/components/ui/media/FileIcon', () => ({ diff --git a/apps/ui/sources/components/sessions/files/content/SearchResultsList.tsx b/apps/ui/sources/components/sessions/files/content/SearchResultsList.tsx index e704739c4..5488167fa 100644 --- a/apps/ui/sources/components/sessions/files/content/SearchResultsList.tsx +++ b/apps/ui/sources/components/sessions/files/content/SearchResultsList.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ActivityIndicator, Platform, View } from 'react-native'; import { Octicons } from '@expo/vector-icons'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Item } from '@/components/ui/lists/Item'; import { FileIcon } from '@/components/ui/media/FileIcon'; import { Typography } from '@/constants/Typography'; @@ -17,9 +17,9 @@ type SearchResultsListProps = { onFilePress: (file: FileItem) => void; }; -function renderFileIconForSearch(file: FileItem) { +function renderFileIconForSearch(file: FileItem, theme: any) { if (file.fileType === 'folder') { - return <Octicons name="file-directory" size={29} color="#007AFF" />; + return <Octicons name="file-directory" size={29} color={theme.colors.accent.blue} />; } return <FileIcon fileName={file.fileName} size={29} />; @@ -131,7 +131,7 @@ export function SearchResultsList({ key={`file-${file.fullPath}-${index}`} title={file.fileName} subtitle={file.filePath || t('files.projectRoot')} - icon={renderFileIconForSearch(file)} + icon={renderFileIconForSearch(file, theme)} density="compact" onPress={file.fileType === 'file' ? () => onFilePress(file) : undefined} showDivider={index < searchResults.length - 1} diff --git a/apps/ui/sources/components/sessions/files/content/review/ChangedFilesReviewOutline.tsx b/apps/ui/sources/components/sessions/files/content/review/ChangedFilesReviewOutline.tsx index 5f5e20f5c..cb55edea0 100644 --- a/apps/ui/sources/components/sessions/files/content/review/ChangedFilesReviewOutline.tsx +++ b/apps/ui/sources/components/sessions/files/content/review/ChangedFilesReviewOutline.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { Platform, Pressable, TextInput, View } from 'react-native'; +import { Platform, Pressable, View } from 'react-native'; import { Octicons } from '@expo/vector-icons'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import type { ScmFileStatus } from '@/scm/scmStatusFiles'; import { t } from '@/text'; diff --git a/apps/ui/sources/components/sessions/files/file/FileActionToolbar.test.tsx b/apps/ui/sources/components/sessions/files/file/FileActionToolbar.test.tsx index c9f8ff0ef..d6e9249e8 100644 --- a/apps/ui/sources/components/sessions/files/file/FileActionToolbar.test.tsx +++ b/apps/ui/sources/components/sessions/files/file/FileActionToolbar.test.tsx @@ -13,8 +13,9 @@ vi.mock('react-native', () => ({ Pressable: 'Pressable', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/components/sessions/files/file/FileActionToolbar.tsx b/apps/ui/sources/components/sessions/files/file/FileActionToolbar.tsx index 005500ce7..0daa1b3f4 100644 --- a/apps/ui/sources/components/sessions/files/file/FileActionToolbar.tsx +++ b/apps/ui/sources/components/sessions/files/file/FileActionToolbar.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Platform, Pressable, View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import type { ScmProjectInFlightOperation } from '@/sync/runtime/orchestration/projectManager'; diff --git a/apps/ui/sources/components/sessions/files/file/FileContentPanel.test.tsx b/apps/ui/sources/components/sessions/files/file/FileContentPanel.test.tsx index eab614ec3..8969f55a1 100644 --- a/apps/ui/sources/components/sessions/files/file/FileContentPanel.test.tsx +++ b/apps/ui/sources/components/sessions/files/file/FileContentPanel.test.tsx @@ -9,8 +9,9 @@ vi.mock('react-native', () => ({ View: 'View', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/components/ui/code/view/CodeLinesView', () => ({ diff --git a/apps/ui/sources/components/sessions/files/file/FileContentPanel.tsx b/apps/ui/sources/components/sessions/files/file/FileContentPanel.tsx index 1fa413b97..74f35716a 100644 --- a/apps/ui/sources/components/sessions/files/file/FileContentPanel.tsx +++ b/apps/ui/sources/components/sessions/files/file/FileContentPanel.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { CodeLinesView } from '@/components/ui/code/view/CodeLinesView'; import { buildCodeLinesFromFile } from '@/components/ui/code/model/buildCodeLinesFromFile'; import { buildCodeLinesFromUnifiedDiff } from '@/components/ui/code/model/buildCodeLinesFromUnifiedDiff'; diff --git a/apps/ui/sources/components/sessions/files/file/FileHeader.test.tsx b/apps/ui/sources/components/sessions/files/file/FileHeader.test.tsx index 6b1529539..77d1ba370 100644 --- a/apps/ui/sources/components/sessions/files/file/FileHeader.test.tsx +++ b/apps/ui/sources/components/sessions/files/file/FileHeader.test.tsx @@ -16,8 +16,9 @@ vi.mock('@/components/ui/media/FileIcon', () => ({ FileIcon: 'FileIcon', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/components/sessions/files/file/FileHeader.tsx b/apps/ui/sources/components/sessions/files/file/FileHeader.tsx index 187bab603..0abd0ee4b 100644 --- a/apps/ui/sources/components/sessions/files/file/FileHeader.tsx +++ b/apps/ui/sources/components/sessions/files/file/FileHeader.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Platform, View } from 'react-native'; import { FileIcon } from '@/components/ui/media/FileIcon'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; type FileHeaderProps = { diff --git a/apps/ui/sources/components/sessions/files/file/FileScreenState.tsx b/apps/ui/sources/components/sessions/files/file/FileScreenState.tsx index 09794eaf8..3dcacd8b9 100644 --- a/apps/ui/sources/components/sessions/files/file/FileScreenState.tsx +++ b/apps/ui/sources/components/sessions/files/file/FileScreenState.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { ActivityIndicator, View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; diff --git a/apps/ui/sources/components/sessions/files/file/editor/FileEditorPanel.tsx b/apps/ui/sources/components/sessions/files/file/editor/FileEditorPanel.tsx index 9b2696873..857a14bd1 100644 --- a/apps/ui/sources/components/sessions/files/file/editor/FileEditorPanel.tsx +++ b/apps/ui/sources/components/sessions/files/file/editor/FileEditorPanel.tsx @@ -3,7 +3,7 @@ import { View } from 'react-native'; import { CodeEditor } from '@/components/ui/code/editor/CodeEditor'; import { Typography } from '@/constants/Typography'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; export function FileEditorPanel(props: Readonly<{ theme: any; diff --git a/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.featureGate.test.tsx b/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.featureGate.test.tsx index 4d8e1d4f6..29e272a63 100644 --- a/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.featureGate.test.tsx +++ b/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.featureGate.test.tsx @@ -104,6 +104,7 @@ vi.mock('@/sync/domains/state/storage', () => ({ })); vi.mock('@/sync/domains/server/serverProfiles', () => ({ + getActiveServerSnapshot: () => ({ serverId: 's1', generation: 1 }), listServerProfiles: () => [{ id: 's1', name: 'cloud', serverUrl: 'https://api.happier.dev' }], })); @@ -135,4 +136,3 @@ describe('SessionGettingStartedGuidance (feature gate)', () => { expect(tree.toJSON()).toBeNull(); }); }); - diff --git a/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.tsx b/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.tsx index 37d9a20da..46d94e714 100644 --- a/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.tsx +++ b/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, Pressable, ScrollView, Text, View } from 'react-native'; +import { Platform, Pressable, ScrollView, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; @@ -19,6 +19,8 @@ import { getFeatureBuildPolicyDecision } from '@/sync/domains/features/featureBu import type { SessionGettingStartedDecisionKind } from './gettingStartedModel'; import type { SessionGettingStartedViewModel } from './gettingStartedModel'; import { buildSessionGettingStartedViewModel } from './gettingStartedModel'; +import { Text } from '@/components/ui/text/Text'; + export type SessionGettingStartedGuidanceVariant = 'phone' | 'sidebar' | 'primaryPane' | 'newSessionBlocking'; @@ -285,6 +287,7 @@ export function SessionGettingStartedGuidanceView(props: Readonly<{ contentContainerStyle={styles.contentContainer} keyboardShouldPersistTaps="handled" > + <View testID={`session-getting-started-kind-${model.kind}`} style={{ width: 0, height: 0, overflow: 'hidden' }} /> {steps.length > 0 ? ( <View style={styles.stepsContainer}> @@ -319,6 +322,7 @@ export function SessionGettingStartedGuidanceView(props: Readonly<{ {model.kind === 'create_session' && model.onStartNewSession ? ( <View style={styles.buttonWrapper}> <RoundButton + testID="session-getting-started-start-new-session" title={t('components.emptySessionsTablet.startNewSessionButton')} onPress={model.onStartNewSession} size="normal" diff --git a/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.view.test.tsx b/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.view.test.tsx index 4f1fe0c31..99695d5b5 100644 --- a/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.view.test.tsx +++ b/apps/ui/sources/components/sessions/guidance/SessionGettingStartedGuidance.view.test.tsx @@ -117,6 +117,7 @@ describe('SessionGettingStartedGuidanceView', () => { expect(() => tree.root.findByProps({ testID: 'session-getting-started-copy-all' } as any)).toThrow(); expect(() => tree.root.findByProps({ testID: 'session-getting-started-scroll' } as any)).not.toThrow(); + expect(() => tree.root.findByProps({ testID: 'session-getting-started-kind-connect_machine' } as any)).not.toThrow(); expect(() => tree.root.findByProps({ testID: 'session-getting-started-step-create_session' } as any)).not.toThrow(); clipboardMocks.setStringAsync.mockClear(); diff --git a/apps/ui/sources/components/sessions/memorySearch/MemorySearchScreen.tsx b/apps/ui/sources/components/sessions/memorySearch/MemorySearchScreen.tsx index 6751338bc..a65c143a0 100644 --- a/apps/ui/sources/components/sessions/memorySearch/MemorySearchScreen.tsx +++ b/apps/ui/sources/components/sessions/memorySearch/MemorySearchScreen.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import { useRouter } from 'expo-router'; @@ -9,6 +9,8 @@ import { machineRpcWithServerScope } from '@/sync/runtime/orchestration/serverSc import { useFeatureEnabled } from '@/hooks/server/useFeatureEnabled'; import { MemorySearchResultV1Schema, RPC_METHODS } from '@happier-dev/protocol'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export const MemorySearchScreen = React.memo(function MemorySearchScreen() { const { theme } = useUnistyles(); diff --git a/apps/ui/sources/components/sessions/new/components/CliNotDetectedBanner.tsx b/apps/ui/sources/components/sessions/new/components/CliNotDetectedBanner.tsx index 6b8209716..173e341ae 100644 --- a/apps/ui/sources/components/sessions/new/components/CliNotDetectedBanner.tsx +++ b/apps/ui/sources/components/sessions/new/components/CliNotDetectedBanner.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { Linking, Platform, Pressable, Text, View } from 'react-native'; +import { Linking, Platform, Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { getAgentCore, type AgentId } from '@/agents/catalog/catalog'; +import { Text } from '@/components/ui/text/Text'; + export type CliNotDetectedBannerDismissScope = 'machine' | 'global' | 'temporary'; diff --git a/apps/ui/sources/components/sessions/new/components/ConnectedServicesAuthModal.test.tsx b/apps/ui/sources/components/sessions/new/components/ConnectedServicesAuthModal.test.tsx index af5bac71a..7f5c66943 100644 --- a/apps/ui/sources/components/sessions/new/components/ConnectedServicesAuthModal.test.tsx +++ b/apps/ui/sources/components/sessions/new/components/ConnectedServicesAuthModal.test.tsx @@ -40,6 +40,7 @@ vi.mock('@/hooks/server/useFeatureEnabled', () => ({ vi.mock('@/sync/store/hooks', () => ({ useSettings: () => useSettingsSpy(), + useLocalSetting: () => 1, })); vi.mock('@/sync/api/account/apiConnectedServicesQuotasV2', () => ({ diff --git a/apps/ui/sources/components/sessions/new/components/ConnectedServicesAuthModal.tsx b/apps/ui/sources/components/sessions/new/components/ConnectedServicesAuthModal.tsx index a9ac82403..474780c94 100644 --- a/apps/ui/sources/components/sessions/new/components/ConnectedServicesAuthModal.tsx +++ b/apps/ui/sources/components/sessions/new/components/ConnectedServicesAuthModal.tsx @@ -2,11 +2,12 @@ import * as React from 'react'; import { View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { ItemList } from '@/components/ui/lists/ItemList'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { t } from '@/text'; import { resolveConnectedServiceDefaultProfileId } from '@/sync/domains/connectedServices/connectedServiceProfilePreferences'; import { connectedServiceProfileKey } from '@/sync/domains/connectedServices/connectedServiceProfilePreferences'; @@ -37,6 +38,7 @@ export const ConnectedServicesAuthModal = React.memo(function ConnectedServicesA defaultProfileIdByServiceId?: Readonly<Record<string, string | undefined>>; onOpenSettings?: () => void; }>) { + const { theme } = useUnistyles(); const [bindingsByServiceId, setBindingsByServiceId] = React.useState<Readonly<Record<string, ConnectedServicesServiceBinding | undefined>>>( props.bindingsByServiceId, ); @@ -88,14 +90,14 @@ export const ConnectedServicesAuthModal = React.memo(function ConnectedServicesA <Item title={'Backend native auth'} subtitle={'Use your local CLI login / API keys'} - icon={<Ionicons name={mode === 'native' ? 'checkmark-circle' : 'ellipse-outline'} size={22} color="#007AFF" />} + icon={<Ionicons name={mode === 'native' ? 'checkmark-circle' : 'ellipse-outline'} size={22} color={theme.colors.accent.blue} />} onPress={() => handleSetBindingForService(serviceId, { source: 'native' })} showChevron={false} /> <Item title={'Use connected services'} subtitle={'Fetch and materialize from Happier cloud'} - icon={<Ionicons name={mode === 'connected' ? 'checkmark-circle' : 'ellipse-outline'} size={22} color="#007AFF" />} + icon={<Ionicons name={mode === 'connected' ? 'checkmark-circle' : 'ellipse-outline'} size={22} color={theme.colors.accent.blue} />} onPress={() => { if (connected.length === 0) { props.onOpenSettings?.(); @@ -111,7 +113,7 @@ export const ConnectedServicesAuthModal = React.memo(function ConnectedServicesA <Item title={'Not connected'} subtitle={'Tap to open settings'} - icon={<Ionicons name="warning-outline" size={20} color="#FF9500" />} + icon={<Ionicons name="warning-outline" size={20} color={theme.colors.accent.orange} />} onPress={props.onOpenSettings} /> ) : ( @@ -134,7 +136,7 @@ export const ConnectedServicesAuthModal = React.memo(function ConnectedServicesA <Ionicons name={effectiveProfileId === opt.profileId ? 'checkmark-circle' : 'ellipse-outline'} size={20} - color="#007AFF" + color={theme.colors.accent.blue} /> } rightElement={badges.length > 0 ? <ConnectedServiceQuotaBadgesView badges={badges} /> : undefined} @@ -153,7 +155,7 @@ export const ConnectedServicesAuthModal = React.memo(function ConnectedServicesA <ItemGroup> <Item title={t('common.close') ?? 'Done'} - icon={<Ionicons name="close-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="close-outline" size={22} color={theme.colors.accent.blue} />} onPress={props.onClose} showChevron={false} /> diff --git a/apps/ui/sources/components/sessions/new/components/EnvironmentVariablesPreviewModal.tsx b/apps/ui/sources/components/sessions/new/components/EnvironmentVariablesPreviewModal.tsx index 17e9e24ba..c1e748912 100644 --- a/apps/ui/sources/components/sessions/new/components/EnvironmentVariablesPreviewModal.tsx +++ b/apps/ui/sources/components/sessions/new/components/EnvironmentVariablesPreviewModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, ScrollView, Pressable, Platform, useWindowDimensions } from 'react-native'; +import { View, ScrollView, Pressable, Platform, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -8,6 +8,8 @@ import { Item } from '@/components/ui/lists/Item'; import { useEnvironmentVariables } from '@/hooks/server/useEnvironmentVariables'; import { t } from '@/text'; import { formatEnvVarTemplate, parseEnvVarTemplate } from '@/utils/profiles/envVarTemplate'; +import { Text } from '@/components/ui/text/Text'; + export interface EnvironmentVariablesPreviewModalProps { environmentVariables: Record<string, string>; diff --git a/apps/ui/sources/components/sessions/new/components/MachineCliGlyphs.tsx b/apps/ui/sources/components/sessions/new/components/MachineCliGlyphs.tsx index 788c57688..0b67459f8 100644 --- a/apps/ui/sources/components/sessions/new/components/MachineCliGlyphs.tsx +++ b/apps/ui/sources/components/sessions/new/components/MachineCliGlyphs.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { Modal } from '@/modal'; @@ -9,6 +9,8 @@ import { CAPABILITIES_REQUEST_NEW_SESSION } from '@/capabilities/requests'; import { getAgentCore, getAgentCliGlyph } from '@/agents/catalog/catalog'; import { useEnabledAgentIds } from '@/agents/hooks/useEnabledAgentIds'; import type { CapabilityId } from '@/sync/api/capabilities/capabilitiesProtocol'; +import { Text } from '@/components/ui/text/Text'; + type Props = { machineId: string; diff --git a/apps/ui/sources/components/sessions/new/components/NewSessionSimplePanel.tsx b/apps/ui/sources/components/sessions/new/components/NewSessionSimplePanel.tsx index 278db6e71..0075301ea 100644 --- a/apps/ui/sources/components/sessions/new/components/NewSessionSimplePanel.tsx +++ b/apps/ui/sources/components/sessions/new/components/NewSessionSimplePanel.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type { ViewStyle } from 'react-native'; -import { Platform, Pressable, Text, View } from 'react-native'; +import { Platform, Pressable, View } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { SessionTypeSelectorRows } from '@/components/ui/forms/SessionTypeSelector'; @@ -15,6 +15,8 @@ import { useAttachmentsUploadConfig } from '@/components/sessions/attachments/us import { useAttachmentDraftManager } from '@/components/sessions/attachments/useAttachmentDraftManager'; import { formatAttachmentsBlock, uploadAttachmentDraftsToSession } from '@/components/sessions/attachments/uploadAttachmentDraftsToSession'; import { sync } from '@/sync/sync'; +import { Text } from '@/components/ui/text/Text'; + export function NewSessionSimplePanel(props: Readonly<{ popoverBoundaryRef: React.RefObject<View>; @@ -174,6 +176,7 @@ export function NewSessionSimplePanel(props: Readonly<{ width: '100%', alignSelf: 'center', paddingTop: props.safeAreaTop + props.newSessionTopPadding, + ...(Platform.OS !== 'web' ? { marginTop: 'auto' } : {}), }} > {/* Session type selector only if enabled via experiments */} diff --git a/apps/ui/sources/components/sessions/new/components/NewSessionWizard.tsx b/apps/ui/sources/components/sessions/new/components/NewSessionWizard.tsx index 7751f14ba..fbcd2e17f 100644 --- a/apps/ui/sources/components/sessions/new/components/NewSessionWizard.tsx +++ b/apps/ui/sources/components/sessions/new/components/NewSessionWizard.tsx @@ -1,6 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; import * as React from 'react'; -import { Platform, Pressable, ScrollView, Text, View } from 'react-native'; +import { Platform, Pressable, ScrollView, View } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import { LinearGradient } from 'expo-linear-gradient'; import Color from 'color'; @@ -34,6 +34,8 @@ import { useAttachmentsUploadConfig } from '@/components/sessions/attachments/us import { useAttachmentDraftManager } from '@/components/sessions/attachments/useAttachmentDraftManager'; import { formatAttachmentsBlock, uploadAttachmentDraftsToSession } from '@/components/sessions/attachments/uploadAttachmentDraftsToSession'; import { sync } from '@/sync/sync'; +import { Text } from '@/components/ui/text/Text'; + export interface NewSessionWizardLayoutProps { theme: any; @@ -786,12 +788,12 @@ export const NewSessionWizard = React.memo(function NewSessionWizard(props: NewS onModelModeChange={setModelMode} modelOptionsOverride={modelOptions} modelOptionsOverrideProbe={modelOptionsProbe} - acpSessionModeOptionsOverride={props.agentProps.acpSessionModeOptions} - acpSessionModeSelectedIdOverride={props.agentProps.acpSessionModeId ?? null} - acpSessionModeOptionsOverrideProbe={props.agentProps.acpSessionModeProbe} + acpSessionModeOptionsOverride={props.agent.acpSessionModeOptions} + acpSessionModeSelectedIdOverride={props.agent.acpSessionModeId ?? null} + acpSessionModeOptionsOverrideProbe={props.agent.acpSessionModeProbe} onAcpSessionModeChange={ - (props.agentProps.acpSessionModeOptions?.length ?? 0) > 0 && props.agentProps.setAcpSessionModeId - ? (modeId) => props.agentProps.setAcpSessionModeId?.(modeId === 'default' ? null : modeId) + (props.agent.acpSessionModeOptions?.length ?? 0) > 0 && props.agent.setAcpSessionModeId + ? (modeId) => props.agent.setAcpSessionModeId?.(modeId === 'default' ? null : modeId) : undefined } connectionStatus={connectionStatus} diff --git a/apps/ui/sources/components/sessions/new/components/PathSelector.tsx b/apps/ui/sources/components/sessions/new/components/PathSelector.tsx index dd5beffa7..ff3f410c5 100644 --- a/apps/ui/sources/components/sessions/new/components/PathSelector.tsx +++ b/apps/ui/sources/components/sessions/new/components/PathSelector.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { View, Pressable, TextInput, Platform } from 'react-native'; +import { View, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; @@ -9,6 +9,8 @@ import { Typography } from '@/constants/Typography'; import { formatPathRelativeToHome } from '@/utils/sessions/sessionUtils'; import { resolveAbsolutePath } from '@/utils/path/pathUtils'; import { t } from '@/text'; +import { TextInput } from '@/components/ui/text/Text'; + type PathSelectorBaseProps = { machineHomeDir: string; @@ -96,8 +98,8 @@ export function PathSelector({ const { theme, rt } = useUnistyles(); const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const styles = stylesheet; - const inputRef = useRef<TextInput>(null); - const searchInputRef = useRef<TextInput>(null); + const inputRef = useRef<React.ElementRef<typeof TextInput> | null>(null); + const searchInputRef = useRef<React.ElementRef<typeof TextInput> | null>(null); const searchWasFocusedRef = useRef(false); const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); diff --git a/apps/ui/sources/components/sessions/new/components/ProfileCompatibilityIcon.tsx b/apps/ui/sources/components/sessions/new/components/ProfileCompatibilityIcon.tsx index d4d4554c2..43bc52fbf 100644 --- a/apps/ui/sources/components/sessions/new/components/ProfileCompatibilityIcon.tsx +++ b/apps/ui/sources/components/sessions/new/components/ProfileCompatibilityIcon.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { Text, View, type ViewStyle } from 'react-native'; +import { View, ViewStyle } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import type { AIBackendProfile } from '@/sync/domains/settings/settings'; import { isProfileCompatibleWithAgent } from '@/sync/domains/settings/settings'; import { getAgentCliGlyph, getAgentCore } from '@/agents/catalog/catalog'; import { useEnabledAgentIds } from '@/agents/hooks/useEnabledAgentIds'; +import { Text } from '@/components/ui/text/Text'; + type Props = { profile: Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'>; diff --git a/apps/ui/sources/components/sessions/new/components/ServerScopedMachineSelector.tsx b/apps/ui/sources/components/sessions/new/components/ServerScopedMachineSelector.tsx index b931fd815..4c56d41ab 100644 --- a/apps/ui/sources/components/sessions/new/components/ServerScopedMachineSelector.tsx +++ b/apps/ui/sources/components/sessions/new/components/ServerScopedMachineSelector.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; @@ -12,6 +12,8 @@ import type { ServerScopedMachine, ServerScopedMachineGroup, } from '@/components/sessions/new/hooks/machines/useServerScopedMachineOptions'; +import { Text } from '@/components/ui/text/Text'; + type ServerScopedMachineSelectorProps = Readonly<{ groups: ReadonlyArray<ServerScopedMachineGroup>; diff --git a/apps/ui/sources/components/sessions/new/components/WizardSectionHeaderRow.test.ts b/apps/ui/sources/components/sessions/new/components/WizardSectionHeaderRow.test.ts index e9e43472f..977a40264 100644 --- a/apps/ui/sources/components/sessions/new/components/WizardSectionHeaderRow.test.ts +++ b/apps/ui/sources/components/sessions/new/components/WizardSectionHeaderRow.test.ts @@ -32,7 +32,8 @@ describe('WizardSectionHeaderRow', () => { const children = React.Children.toArray(rootView?.props.children).filter(React.isValidElement); const childTypes = children.map((child) => child.type); - expect(childTypes).toEqual(['Ionicons', 'Text', 'Pressable']); + expect(childTypes[0]).toBe('Ionicons'); + expect(childTypes[2]).toBe('Pressable'); expect((children[1]?.props as { children?: unknown }).children).toBe('Select Machine'); const action = tree?.root.findByProps({ accessibilityLabel: 'Refresh machines' }); diff --git a/apps/ui/sources/components/sessions/new/components/WizardSectionHeaderRow.tsx b/apps/ui/sources/components/sessions/new/components/WizardSectionHeaderRow.tsx index d052c0d1d..2d59e926a 100644 --- a/apps/ui/sources/components/sessions/new/components/WizardSectionHeaderRow.tsx +++ b/apps/ui/sources/components/sessions/new/components/WizardSectionHeaderRow.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/ui/text/Text'; + export type WizardSectionHeaderRowAction = { accessibilityLabel: string; diff --git a/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.acpSessionModeSeed.test.ts b/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.acpSessionModeSeed.test.ts index d7b0a9520..07be7880b 100644 --- a/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.acpSessionModeSeed.test.ts +++ b/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.acpSessionModeSeed.test.ts @@ -10,7 +10,7 @@ import type { UseMachineEnvPresenceResult } from '@/hooks/machine/useMachineEnvP async function setupHarness() { const publishModeSpy = vi.fn(async (_params: any) => {}); const sendMessageSpy = vi.fn(async () => {}); - const machineSpawnNewSessionSpy = vi.fn(async () => ({ type: 'success', sessionId: 'sess_new' })); + const machineSpawnNewSessionSpy = vi.fn(async (..._args: any[]) => ({ type: 'success', sessionId: 'sess_new' })); vi.doMock('@/text', () => ({ t: (key: string) => key })); vi.doMock('@/modal', () => ({ Modal: { alert: vi.fn(), confirm: vi.fn(async () => false) } })); @@ -71,7 +71,7 @@ async function setupHarness() { vi.doMock('@/agents/runtime/acpRuntimeResume', () => ({ describeAcpLoadSessionSupport: vi.fn(() => ({ kind: 'unknown' })) })); vi.doMock('@/agents/runtime/resumeCapabilities', () => ({ canAgentResume: vi.fn(() => false) })); vi.doMock('@/components/sessions/new/modules/formatResumeSupportDetailCode', () => ({ formatResumeSupportDetailCode: vi.fn(() => '') })); - vi.doMock('@/sync/ops', () => ({ machineSpawnNewSession: (...args: unknown[]) => machineSpawnNewSessionSpy(...args) })); + vi.doMock('@/sync/ops', () => ({ machineSpawnNewSession: machineSpawnNewSessionSpy })); const { useCreateNewSession } = await import('./useCreateNewSession'); return { useCreateNewSession, publishModeSpy, sendMessageSpy, machineSpawnNewSessionSpy }; @@ -155,4 +155,3 @@ describe('useCreateNewSession (ACP mode seeding)', () => { expect(publishOrder).toBeLessThan(sendOrder); }); }); - diff --git a/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.daemonUnavailable.test.ts b/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.daemonUnavailable.test.ts new file mode 100644 index 000000000..2ce4a9c53 --- /dev/null +++ b/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.daemonUnavailable.test.ts @@ -0,0 +1,321 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { PermissionMode, ModelMode } from '@/sync/domains/permissions/permissionTypes'; +import type { Settings } from '@/sync/domains/settings/settings'; +import type { UseMachineEnvPresenceResult } from '@/hooks/machine/useMachineEnvPresence'; +import { SPAWN_SESSION_ERROR_CODES } from '@happier-dev/protocol'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function setupHarness() { + const modalAlertSpy = vi.fn((..._args: unknown[]) => {}); + const machineSpawnNewSessionSpy = vi.fn(async () => ({ + type: 'error' as const, + errorCode: SPAWN_SESSION_ERROR_CODES.DAEMON_RPC_UNAVAILABLE, + errorMessage: 'Daemon RPC is not available', + })); + + vi.doMock('@/text', () => ({ + t: (key: string, params?: Record<string, unknown>) => { + if (key === 'status.lastSeen') return `status.lastSeen:${String(params?.time ?? '')}`; + if (key === 'time.minutesAgo') return `time.minutesAgo:${String(params?.count ?? '')}`; + if (key === 'time.hoursAgo') return `time.hoursAgo:${String(params?.count ?? '')}`; + return key; + }, + })); + vi.doMock('@/modal', () => ({ Modal: { alert: modalAlertSpy, confirm: vi.fn(async () => false) } })); + vi.doMock('@/sync/sync', () => ({ + sync: { + applySettings: vi.fn(), + encryption: { encryptRaw: vi.fn(), encryptAutomationTemplateRaw: vi.fn() }, + decryptSecretValue: vi.fn(), + refreshAutomations: vi.fn(async () => {}), + refreshSessions: vi.fn(async () => {}), + refreshMachines: vi.fn(async () => {}), + sendMessage: vi.fn(async () => {}), + }, + })); + vi.doMock('@/sync/domains/state/storage', () => ({ + storage: { + getState: () => ({ + settings: {}, + machines: { m1: { id: 'm1' } }, + updateSessionPermissionMode: vi.fn(), + updateSessionModelMode: vi.fn(), + updateSessionDraft: vi.fn(), + }), + }, + })); + vi.doMock('@/sync/domains/state/persistence', () => ({ clearNewSessionDraft: vi.fn() })); + vi.doMock('@/sync/domains/server/serverRuntime', () => ({ + getActiveServerSnapshot: vi.fn(() => ({ + serverId: 'server-a', + serverUrl: 'https://server-a.example.test', + kind: 'custom', + generation: 1, + })), + setActiveServer: vi.fn(), + })); + vi.doMock('@/sync/runtime/orchestration/connectionManager', () => ({ + switchConnectionToActiveServer: vi.fn(async () => ({ token: 'next-token', secret: 'next-secret' })), + })); + vi.doMock('@/sync/domains/settings/terminalSettings', () => ({ resolveTerminalSpawnOptions: vi.fn(() => null) })); + vi.doMock('@/hooks/server/useMachineCapabilitiesCache', () => ({ + getMachineCapabilitiesSnapshot: vi.fn(() => ({ supported: true, response: { protocolVersion: 1, results: {} } })), + prefetchMachineCapabilities: vi.fn(async () => {}), + })); + vi.doMock('@/agents/catalog/catalog', async () => { + const actual = await vi.importActual<typeof import('@/agents/catalog/catalog')>('@/agents/catalog/catalog'); + return { + ...actual, + getAgentCore: vi.fn(() => ({ model: { supportsSelection: false } })), + buildSpawnEnvironmentVariablesFromUiState: vi.fn((opts: { environmentVariables?: Record<string, string> }) => opts.environmentVariables), + buildSpawnSessionExtrasFromUiState: vi.fn(() => ({})), + getAgentResumeExperimentsFromSettings: vi.fn(() => ({})), + getNewSessionPreflightIssues: vi.fn(() => []), + getResumeRuntimeSupportPrefetchPlan: vi.fn(() => null), + buildResumeCapabilityOptionsFromUiState: vi.fn(() => ({})), + }; + }); + vi.doMock('@/agents/runtime/acpRuntimeResume', () => ({ describeAcpLoadSessionSupport: vi.fn(() => ({ kind: 'unknown' })) })); + vi.doMock('@/agents/runtime/resumeCapabilities', () => ({ canAgentResume: vi.fn(() => false) })); + vi.doMock('@/components/sessions/new/modules/formatResumeSupportDetailCode', () => ({ formatResumeSupportDetailCode: vi.fn(() => '') })); + vi.doMock('@/sync/ops', () => ({ machineSpawnNewSession: machineSpawnNewSessionSpy })); + + const { useCreateNewSession } = await import('./useCreateNewSession'); + return { useCreateNewSession, modalAlertSpy, machineSpawnNewSessionSpy }; +} + +describe('useCreateNewSession (daemon unavailable UX)', () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-05T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('shows a daemon-unavailable alert with a Retry action', async () => { + const { useCreateNewSession, modalAlertSpy } = await setupHarness(); + + let handleCreateSession: null | (() => Promise<void>) = null; + const setIsCreating = vi.fn(); + const settings = { experiments: false } as unknown as Settings; + const machineEnvPresence: UseMachineEnvPresenceResult = { + isPreviewEnvSupported: false, + isLoading: false, + meta: {}, + refreshedAt: null, + refresh: () => {}, + }; + + function Test() { + const hook = useCreateNewSession({ + router: { push: vi.fn(), replace: vi.fn() }, + selectedMachineId: 'm1', + selectedPath: '/tmp', + selectedMachine: { id: 'm1', active: false, activeAt: Date.now() - 5 * 60_000, metadata: { host: 'devbox' } }, + setIsCreating, + setIsResumeSupportChecking: vi.fn(), + sessionType: 'simple', + settings, + useProfiles: false, + selectedProfileId: null, + profileMap: new Map(), + recentMachinePaths: [], + agentType: 'opencode' as any, + permissionMode: 'default' as PermissionMode, + modelMode: 'default' as ModelMode, + sessionPrompt: '', + resumeSessionId: '', + agentNewSessionOptions: null, + automationDraft: null, + machineEnvPresence, + secrets: [], + secretBindingsByProfileId: {}, + selectedSecretIdByProfileIdByEnvVarName: {}, + sessionOnlySecretValueByProfileIdByEnvVarName: {}, + selectedMachineCapabilities: {}, + targetServerId: null, + allowedTargetServerIds: undefined, + }); + handleCreateSession = hook.handleCreateSession as any; + return null; + } + + let tree: renderer.ReactTestRenderer | null = null; + await act(async () => { + tree = renderer.create(React.createElement(Test)); + }); + if (!handleCreateSession) throw new Error('expected handleCreateSession to be set'); + + await act(async () => { + const p = handleCreateSession(); + await vi.runAllTimersAsync(); + await p; + }); + + expect(modalAlertSpy).toHaveBeenCalled(); + const args = modalAlertSpy.mock.calls[0] ?? []; + expect(args[0]).toBe('newSession.daemonRpcUnavailableTitle'); + expect(String(args[1] ?? '')).toContain('newSession.daemonRpcUnavailableBody'); + expect(String(args[1] ?? '')).toContain('status.lastSeen:time.minutesAgo:5'); + expect(Array.isArray(args[2])).toBe(true); + const buttons = args[2] as any[]; + expect(buttons.some((b) => b?.text === 'common.retry' && typeof b?.onPress === 'function')).toBe(true); + await act(async () => { + tree?.unmount(); + }); + }); + + it('does not retry after unmount when the alert Retry action is pressed', async () => { + const { useCreateNewSession, modalAlertSpy, machineSpawnNewSessionSpy } = await setupHarness(); + + let handleCreateSession: null | (() => Promise<void>) = null; + const setIsCreating = vi.fn(); + const settings = { experiments: false } as unknown as Settings; + const machineEnvPresence: UseMachineEnvPresenceResult = { + isPreviewEnvSupported: false, + isLoading: false, + meta: {}, + refreshedAt: null, + refresh: () => {}, + }; + + function Test() { + const hook = useCreateNewSession({ + router: { push: vi.fn(), replace: vi.fn() }, + selectedMachineId: 'm1', + selectedPath: '/tmp', + selectedMachine: { id: 'm1', active: false, activeAt: Date.now() - 5 * 60_000, metadata: { host: 'devbox' } }, + setIsCreating, + setIsResumeSupportChecking: vi.fn(), + sessionType: 'simple', + settings, + useProfiles: false, + selectedProfileId: null, + profileMap: new Map(), + recentMachinePaths: [], + agentType: 'opencode' as any, + permissionMode: 'default' as PermissionMode, + modelMode: 'default' as ModelMode, + sessionPrompt: '', + resumeSessionId: '', + agentNewSessionOptions: null, + automationDraft: null, + machineEnvPresence, + secrets: [], + secretBindingsByProfileId: {}, + selectedSecretIdByProfileIdByEnvVarName: {}, + sessionOnlySecretValueByProfileIdByEnvVarName: {}, + selectedMachineCapabilities: {}, + targetServerId: null, + allowedTargetServerIds: undefined, + }); + handleCreateSession = hook.handleCreateSession as any; + return null; + } + + let tree: renderer.ReactTestRenderer | null = null; + await act(async () => { + tree = renderer.create(React.createElement(Test)); + }); + if (!handleCreateSession) throw new Error('expected handleCreateSession to be set'); + + await act(async () => { + const p = handleCreateSession(); + await vi.runAllTimersAsync(); + await p; + }); + + expect(machineSpawnNewSessionSpy).toHaveBeenCalledTimes(1); + expect(modalAlertSpy).toHaveBeenCalled(); + + const buttons = (modalAlertSpy.mock.calls[0]?.[2] ?? []) as any[]; + const retry = buttons.find((b) => b?.text === 'common.retry'); + expect(typeof retry?.onPress).toBe('function'); + + await act(async () => { + tree?.unmount(); + }); + + await act(async () => { + retry.onPress(); + await vi.runAllTimersAsync(); + }); + + expect(machineSpawnNewSessionSpy).toHaveBeenCalledTimes(1); + }); + + it('does not auto-retry in the hook before showing the daemon-unavailable alert', async () => { + const { useCreateNewSession, modalAlertSpy, machineSpawnNewSessionSpy } = await setupHarness(); + + machineSpawnNewSessionSpy.mockResolvedValueOnce({ + type: 'error' as const, + errorCode: SPAWN_SESSION_ERROR_CODES.DAEMON_RPC_UNAVAILABLE, + errorMessage: 'Daemon RPC is not available', + }); + + let handleCreateSession: null | (() => Promise<void>) = null; + const settings = { experiments: false } as unknown as Settings; + const machineEnvPresence: UseMachineEnvPresenceResult = { + isPreviewEnvSupported: false, + isLoading: false, + meta: {}, + refreshedAt: null, + refresh: () => {}, + }; + + function Test() { + const hook = useCreateNewSession({ + router: { push: vi.fn(), replace: vi.fn() }, + selectedMachineId: 'm1', + selectedPath: '/tmp', + selectedMachine: { id: 'm1', active: true, activeAt: Date.now(), metadata: { host: 'devbox' } }, + setIsCreating: vi.fn(), + setIsResumeSupportChecking: vi.fn(), + sessionType: 'simple', + settings, + useProfiles: false, + selectedProfileId: null, + profileMap: new Map(), + recentMachinePaths: [], + agentType: 'opencode' as any, + permissionMode: 'default' as PermissionMode, + modelMode: 'default' as ModelMode, + sessionPrompt: '', + resumeSessionId: '', + agentNewSessionOptions: null, + automationDraft: null, + machineEnvPresence, + secrets: [], + secretBindingsByProfileId: {}, + selectedSecretIdByProfileIdByEnvVarName: {}, + sessionOnlySecretValueByProfileIdByEnvVarName: {}, + selectedMachineCapabilities: {}, + targetServerId: null, + allowedTargetServerIds: undefined, + }); + handleCreateSession = hook.handleCreateSession as any; + return null; + } + + await act(async () => { + renderer.create(React.createElement(Test)); + }); + if (!handleCreateSession) throw new Error('expected handleCreateSession to be set'); + + await act(async () => { + const p = handleCreateSession(); + await vi.runAllTimersAsync(); + await p; + }); + + expect(machineSpawnNewSessionSpy).toHaveBeenCalledTimes(1); + expect(modalAlertSpy).toHaveBeenCalled(); + }); +}); diff --git a/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.ts b/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.ts index 8ffe02e06..3a90c7413 100644 --- a/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.ts +++ b/apps/ui/sources/components/sessions/new/hooks/useCreateNewSession.ts @@ -38,6 +38,9 @@ import { normalizeAutomationName, validateAutomationTemplateTarget, } from '@/sync/domains/automations/automationValidation'; +import { delay } from '@/utils/timing/time'; +import { showDaemonUnavailableAlert } from '@/utils/errors/daemonUnavailableAlert'; +import { useMountedRef } from '@/hooks/ui/useMountedRef'; export function useCreateNewSession(params: Readonly<{ router: { push: (options: any) => void; replace: (path: any, options?: any) => void }; @@ -83,6 +86,7 @@ export function useCreateNewSession(params: Readonly<{ }>): Readonly<{ handleCreateSession: (opts?: Readonly<{ initialMessage?: 'send' | 'skip'; afterCreated?: (sessionId: string) => void | Promise<void> }>) => void; }> { + const mountedRef = useMountedRef(); const handleCreateSession = React.useCallback(async (opts?: Readonly<{ initialMessage?: 'send' | 'skip'; afterCreated?: (sessionId: string) => void | Promise<void> }>) => { if (!params.selectedMachineId) { Modal.alert(t('common.error'), t('newSession.noMachineSelected')); @@ -438,14 +442,27 @@ export function useCreateNewSession(params: Readonly<{ return 'session' }, }); - } else if (result.type === 'requestToApproveDirectoryCreation') { - Modal.alert(t('common.error'), t('newSession.failedToStart')); - params.setIsCreating(false); - } else if (result.type === 'error') { - const extraDetail = (() => { - switch (result.errorCode) { - case SPAWN_SESSION_ERROR_CODES.RESUME_NOT_SUPPORTED: - return 'Resume is not supported for this agent on this machine.'; + } else if (result.type === 'requestToApproveDirectoryCreation') { + Modal.alert(t('common.error'), t('newSession.failedToStart')); + params.setIsCreating(false); + } else if (result.type === 'error') { + if (result.errorCode === SPAWN_SESSION_ERROR_CODES.DAEMON_RPC_UNAVAILABLE) { + params.setIsCreating(false); + showDaemonUnavailableAlert({ + titleKey: 'newSession.daemonRpcUnavailableTitle', + bodyKey: 'newSession.daemonRpcUnavailableBody', + machine: params.selectedMachine, + onRetry: () => { + void handleCreateSession(opts); + }, + shouldContinue: () => mountedRef.current, + }); + return; + } + const extraDetail = (() => { + switch (result.errorCode) { + case SPAWN_SESSION_ERROR_CODES.RESUME_NOT_SUPPORTED: + return 'Resume is not supported for this agent on this machine.'; case SPAWN_SESSION_ERROR_CODES.CHILD_EXITED_BEFORE_WEBHOOK: return 'The agent process exited before it could connect. Check that the agent CLI is installed and available to the daemon (PATH).'; case SPAWN_SESSION_ERROR_CODES.SESSION_WEBHOOK_TIMEOUT: @@ -473,11 +490,12 @@ export function useCreateNewSession(params: Readonly<{ Modal.alert(t('common.error'), errorMessage); params.setIsCreating(false); } - }, [ - params.agentType, - params.machineEnvPresence.meta, - params.modelMode, - params.permissionMode, + }, [ + mountedRef, + params.agentType, + params.machineEnvPresence.meta, + params.modelMode, + params.permissionMode, params.profileMap, params.recentMachinePaths, params.resumeSessionId, diff --git a/apps/ui/sources/components/sessions/new/hooks/useNewSessionScreenModel.tsx b/apps/ui/sources/components/sessions/new/hooks/useNewSessionScreenModel.tsx index c097d219c..eb7845f13 100644 --- a/apps/ui/sources/components/sessions/new/hooks/useNewSessionScreenModel.tsx +++ b/apps/ui/sources/components/sessions/new/hooks/useNewSessionScreenModel.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Platform, useWindowDimensions, Pressable, Text } from 'react-native'; +import { View, Platform, useWindowDimensions, Pressable } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useAllMachines, storage, useSetting, useSettingMutable, useSettings } from '@/sync/domains/state/storage'; import { useRouter, useLocalSearchParams, useNavigation, usePathname } from 'expo-router'; @@ -36,9 +36,12 @@ import { useMachineEnvPresence } from '@/hooks/machine/useMachineEnvPresence'; import { InteractionManager } from 'react-native'; import { getMachineCapabilitiesSnapshot, prefetchMachineCapabilities, prefetchMachineCapabilitiesIfStale, useMachineCapabilitiesCache } from '@/hooks/server/useMachineCapabilitiesCache'; import { CAPABILITIES_REQUEST_NEW_SESSION } from '@/capabilities/requests'; -import { getInstallableDepRegistryEntries } from '@/capabilities/installableDepsRegistry'; +import { getInstallablesRegistryEntries } from '@/capabilities/installablesRegistry'; +import { planInstallablesBackgroundActions } from '@/capabilities/installablesBackgroundPlan'; import { resolveTerminalSpawnOptions } from '@/sync/domains/settings/terminalSettings'; import type { CapabilityId } from '@/sync/api/capabilities/capabilitiesProtocol'; +import { machineCapabilitiesInvoke } from '@/sync/ops'; +import { resolveInstallablePolicy } from '@/sync/domains/settings/installablesPolicy'; import { buildResumeCapabilityOptionsFromUiState, getAgentResumeExperimentsFromSettings, @@ -90,6 +93,8 @@ import { useNewSessionPreflightModelsState } from '@/components/sessions/new/hoo import { useNewSessionPreflightSessionModesState } from '@/components/sessions/new/hooks/screenModel/useNewSessionPreflightSessionModesState'; import { getActionSpec } from '@happier-dev/protocol'; import { buildActionDraftInput } from '@/sync/domains/actions/buildActionDraftInput'; +import { Text } from '@/components/ui/text/Text'; + // Configuration constants const RECENT_PATHS_DEFAULT_VISIBLE = 5; @@ -676,10 +681,10 @@ export function useNewSessionScreenModel(): NewSessionScreenModel { }); if (relevantKeys.length === 0) return []; - const entries = getInstallableDepRegistryEntries().filter((e) => relevantKeys.includes(e.key)); + const entries = getInstallablesRegistryEntries().filter((e) => relevantKeys.includes(e.key)); const results = selectedMachineCapabilitiesSnapshot?.response.results; return entries.map((entry) => { - const depStatus = entry.getDepStatus(results); + const depStatus = entry.getStatus(results); const detectResult = entry.getDetectResult(results); return { entry, depStatus, detectResult }; }); @@ -717,6 +722,65 @@ export function useNewSessionScreenModel(): NewSessionScreenModel { }); }, [capabilityServerId, machines, selectedMachineId, wizardInstallableDeps]); + const requestedInstallableBackgroundActionsRef = React.useRef<Record<string, true>>({}); + + React.useEffect(() => { + if (!selectedMachineId) return; + if (wizardInstallableDeps.length === 0) return; + + const machine = machines.find((m) => m.id === selectedMachineId); + if (!machine || !isMachineOnline(machine)) return; + + const planned = planInstallablesBackgroundActions({ + installables: wizardInstallableDeps.map(({ entry, depStatus }) => ({ + entry, + status: depStatus, + policy: resolveInstallablePolicy({ + settings: settings as any, + machineId: selectedMachineId, + installableKey: entry.key, + defaults: entry.defaultPolicy, + }), + installSpec: (() => { + const raw = (settings as any)?.[entry.installSpecSettingKey]; + return typeof raw === 'string' ? raw : null; + })(), + })), + }); + + const actions = planned.filter((a) => { + const key = `${selectedMachineId}:${a.installableKey}:${a.request.method}`; + return requestedInstallableBackgroundActionsRef.current[key] !== true; + }); + + if (actions.length === 0) return; + + for (const action of actions) { + requestedInstallableBackgroundActionsRef.current[`${selectedMachineId}:${action.installableKey}:${action.request.method}`] = true; + } + + InteractionManager.runAfterInteractions(() => { + for (const action of actions) { + fireAndForget((async () => { + try { + await machineCapabilitiesInvoke( + selectedMachineId, + action.request, + { serverId: capabilityServerId, timeoutMs: 5 * 60_000 }, + ); + await prefetchMachineCapabilities({ + machineId: selectedMachineId, + serverId: capabilityServerId, + request: CAPABILITIES_REQUEST_NEW_SESSION, + }); + } catch { + // Best-effort: avoid surfacing errors for background installs/updates. + } + })(), { tag: `NewSessionScreenModel.installables.background.${action.installableKey}.${action.request.method}` }); + } + }); + }, [capabilityServerId, machines, selectedMachineId, settings, wizardInstallableDeps]); + React.useEffect(() => { const results = selectedMachineCapabilitiesSnapshot?.response.results as any; const plan = getResumeRuntimeSupportPrefetchPlan({ agentId: agentType, settings, results }); diff --git a/apps/ui/sources/components/sessions/new/hooks/useNewSessionWizardProps.ts b/apps/ui/sources/components/sessions/new/hooks/useNewSessionWizardProps.ts index faa976c80..9769dba4e 100644 --- a/apps/ui/sources/components/sessions/new/hooks/useNewSessionWizardProps.ts +++ b/apps/ui/sources/components/sessions/new/hooks/useNewSessionWizardProps.ts @@ -257,9 +257,9 @@ export function useNewSessionWizardProps(params: Readonly<{ serverId: params.targetServerId, enabled: true, groupTitle: `${tNoParams(entry.groupTitleKey)}${entry.experimental ? ' (experimental)' : ''}`, - depId: entry.depId, - depTitle: entry.depTitle, - depIconName: entry.depIconName as any, + depId: entry.capabilityId, + depTitle: entry.title, + depIconName: entry.iconName as any, depStatus, capabilitiesStatus: params.selectedMachineCapabilities.status, installSpecSettingKey: entry.installSpecSettingKey, diff --git a/apps/ui/sources/components/sessions/pending/PendingMessagesModal.tsx b/apps/ui/sources/components/sessions/pending/PendingMessagesModal.tsx index 5507f877e..d62422600 100644 --- a/apps/ui/sources/components/sessions/pending/PendingMessagesModal.tsx +++ b/apps/ui/sources/components/sessions/pending/PendingMessagesModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -8,6 +8,8 @@ import { sync } from '@/sync/sync'; import { Modal } from '@/modal'; import { sessionAbort } from '@/sync/ops'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { Text } from '@/components/ui/text/Text'; + export function PendingMessagesModal(props: { sessionId: string; onClose: () => void }) { const { theme } = useUnistyles(); diff --git a/apps/ui/sources/components/sessions/pending/PendingQueueIndicator.test.ts b/apps/ui/sources/components/sessions/pending/PendingQueueIndicator.test.ts index 97a786439..631b87d1d 100644 --- a/apps/ui/sources/components/sessions/pending/PendingQueueIndicator.test.ts +++ b/apps/ui/sources/components/sessions/pending/PendingQueueIndicator.test.ts @@ -9,6 +9,8 @@ vi.mock('react-native', () => ({ View: 'View', Text: 'Text', Pressable: 'Pressable', + Platform: { OS: 'web', select: (options: any) => options?.web ?? options?.default ?? options?.ios ?? null }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); vi.mock('@expo/vector-icons', () => ({ @@ -19,12 +21,16 @@ vi.mock('react-native-unistyles', () => ({ useUnistyles: () => ({ theme: { colors: { + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, input: { background: '#fff' }, text: '#000', textSecondary: '#666', }, }, }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input({ colors: { shadow: { color: '#000', opacity: 0.2 } } }) : input) }, })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/components/sessions/pending/PendingQueueIndicator.tsx b/apps/ui/sources/components/sessions/pending/PendingQueueIndicator.tsx index d7f15dc58..1c930d5d1 100644 --- a/apps/ui/sources/components/sessions/pending/PendingQueueIndicator.tsx +++ b/apps/ui/sources/components/sessions/pending/PendingQueueIndicator.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { Modal } from '@/modal'; import { PendingMessagesModal } from './PendingMessagesModal'; import { layout } from '@/components/ui/layout/layout'; +import { Text } from '@/components/ui/text/Text'; + const PENDING_INDICATOR_DEBOUNCE_MS = 250; diff --git a/apps/ui/sources/components/sessions/pending/PendingUserTextMessageView.test.tsx b/apps/ui/sources/components/sessions/pending/PendingUserTextMessageView.test.tsx index c223f09d1..d1439180c 100644 --- a/apps/ui/sources/components/sessions/pending/PendingUserTextMessageView.test.tsx +++ b/apps/ui/sources/components/sessions/pending/PendingUserTextMessageView.test.tsx @@ -9,6 +9,7 @@ vi.mock('react-native', () => ({ View: 'View', Text: 'Text', Pressable: 'Pressable', + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, })); vi.mock('@expo/vector-icons', () => ({ @@ -16,7 +17,9 @@ vi.mock('@expo/vector-icons', () => ({ })); vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (fn: any) => fn({ colors: { userMessageBackground: '#eee' } }) }, + StyleSheet: { + create: (input: any) => (typeof input === 'function' ? input({ colors: { userMessageBackground: '#eee' } }) : input), + }, useUnistyles: () => ({ theme: { colors: { diff --git a/apps/ui/sources/components/sessions/pending/PendingUserTextMessageView.tsx b/apps/ui/sources/components/sessions/pending/PendingUserTextMessageView.tsx index 45455ef68..317e77569 100644 --- a/apps/ui/sources/components/sessions/pending/PendingUserTextMessageView.tsx +++ b/apps/ui/sources/components/sessions/pending/PendingUserTextMessageView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Modal } from '@/modal'; @@ -8,6 +8,8 @@ import type { PendingMessage } from '@/sync/domains/state/storageTypes'; import { MarkdownView } from '@/components/markdown/MarkdownView'; import { PendingMessagesModal } from './PendingMessagesModal'; import { layout } from '@/components/ui/layout/layout'; +import { Text } from '@/components/ui/text/Text'; + export function PendingUserTextMessageView(props: { sessionId: string; diff --git a/apps/ui/sources/components/sessions/plans/messages/PlanOutputMessageCard.tsx b/apps/ui/sources/components/sessions/plans/messages/PlanOutputMessageCard.tsx index f6b466c69..223bf3444 100644 --- a/apps/ui/sources/components/sessions/plans/messages/PlanOutputMessageCard.tsx +++ b/apps/ui/sources/components/sessions/plans/messages/PlanOutputMessageCard.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { PlanOutputV1 } from '@happier-dev/protocol'; import { sync } from '@/sync/sync'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { Text } from '@/components/ui/text/Text'; + export function PlanOutputMessageCard(props: Readonly<{ payload: PlanOutputV1; sessionId: string }>) { const [error, setError] = React.useState<string | null>(null); diff --git a/apps/ui/sources/components/sessions/reviews/comments/ReviewCommentInlineComposer.tsx b/apps/ui/sources/components/sessions/reviews/comments/ReviewCommentInlineComposer.tsx index 6120be1d0..da295ead0 100644 --- a/apps/ui/sources/components/sessions/reviews/comments/ReviewCommentInlineComposer.tsx +++ b/apps/ui/sources/components/sessions/reviews/comments/ReviewCommentInlineComposer.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export function ReviewCommentInlineComposer(props: { value: string; diff --git a/apps/ui/sources/components/sessions/reviews/comments/useCodeLinesReviewComments.test.tsx b/apps/ui/sources/components/sessions/reviews/comments/useCodeLinesReviewComments.test.tsx index c4a8c6e4e..9e4cbe9e3 100644 --- a/apps/ui/sources/components/sessions/reviews/comments/useCodeLinesReviewComments.test.tsx +++ b/apps/ui/sources/components/sessions/reviews/comments/useCodeLinesReviewComments.test.tsx @@ -41,8 +41,9 @@ vi.mock('@/constants/Typography', () => ({ }, })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: ({ children, ...props }: any) => React.createElement('Text', props, children), + TextInput: 'TextInput', })); describe('useCodeLinesReviewComments', () => { diff --git a/apps/ui/sources/components/sessions/reviews/comments/useCodeLinesReviewComments.tsx b/apps/ui/sources/components/sessions/reviews/comments/useCodeLinesReviewComments.tsx index 348fb5fed..dee30e303 100644 --- a/apps/ui/sources/components/sessions/reviews/comments/useCodeLinesReviewComments.tsx +++ b/apps/ui/sources/components/sessions/reviews/comments/useCodeLinesReviewComments.tsx @@ -3,7 +3,7 @@ import { View } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import type { CodeLine } from '@/components/ui/code/model/codeLineTypes'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import type { ReviewCommentDraft, ReviewCommentSource } from '@/sync/domains/input/reviewComments/reviewCommentTypes'; diff --git a/apps/ui/sources/components/sessions/reviews/messages/ReviewCommentsMessageCard.tsx b/apps/ui/sources/components/sessions/reviews/messages/ReviewCommentsMessageCard.tsx index ca6d05e39..2c6b25e21 100644 --- a/apps/ui/sources/components/sessions/reviews/messages/ReviewCommentsMessageCard.tsx +++ b/apps/ui/sources/components/sessions/reviews/messages/ReviewCommentsMessageCard.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ReviewCommentsV1 } from '@/sync/domains/input/reviewComments/reviewCommentMeta'; import type { ReviewCommentAnchor, ReviewCommentSource } from '@/sync/domains/input/reviewComments/reviewCommentTypes'; +import { Text } from '@/components/ui/text/Text'; + export function ReviewCommentsMessageCard(props: { payload: ReviewCommentsV1; diff --git a/apps/ui/sources/components/sessions/reviews/messages/ReviewFindingsMessageCard.test.tsx b/apps/ui/sources/components/sessions/reviews/messages/ReviewFindingsMessageCard.test.tsx index 19bd4ac86..22515c12b 100644 --- a/apps/ui/sources/components/sessions/reviews/messages/ReviewFindingsMessageCard.test.tsx +++ b/apps/ui/sources/components/sessions/reviews/messages/ReviewFindingsMessageCard.test.tsx @@ -6,15 +6,34 @@ import { describe, expect, it, vi } from 'vitest'; const sessionExecutionRunActionSpy = vi.fn(async (..._args: any[]) => ({ ok: true })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - Pressable: 'Pressable', - TextInput: 'TextInput', -})); +vi.mock('react-native', async () => await import('@/dev/reactNativeStub')); vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (fn: any) => fn({ colors: { surfaceHighest: '#111', divider: '#333', text: '#eee', textSecondary: '#aaa' } }) }, + useUnistyles: () => ({ + theme: { + colors: { + surfaceHighest: '#111', + divider: '#333', + text: '#eee', + textSecondary: '#aaa', + shadow: { color: '#000', opacity: 0.1 }, + }, + }, + }), + StyleSheet: { + create: (input: any) => + typeof input === 'function' + ? input({ + colors: { + surfaceHighest: '#111', + divider: '#333', + text: '#eee', + textSecondary: '#aaa', + shadow: { color: '#000', opacity: 0.1 }, + }, + }) + : input, + }, })); vi.mock('@/sync/ops/sessionExecutionRuns', () => ({ diff --git a/apps/ui/sources/components/sessions/reviews/messages/ReviewFindingsMessageCard.tsx b/apps/ui/sources/components/sessions/reviews/messages/ReviewFindingsMessageCard.tsx index d05a12cd5..b825f4b45 100644 --- a/apps/ui/sources/components/sessions/reviews/messages/ReviewFindingsMessageCard.tsx +++ b/apps/ui/sources/components/sessions/reviews/messages/ReviewFindingsMessageCard.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ReviewFindingsV1, ReviewTriageStatus } from '@happier-dev/protocol'; @@ -7,6 +7,8 @@ import type { ReviewFindingsV1, ReviewTriageStatus } from '@happier-dev/protocol import { sessionExecutionRunAction } from '@/sync/ops/sessionExecutionRuns'; import { sync } from '@/sync/sync'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { Text, TextInput } from '@/components/ui/text/Text'; + function formatFindingLocation(finding: ReviewFindingsV1['findings'][number]): string | null { if (!finding.filePath) return null; diff --git a/apps/ui/sources/components/sessions/runs/ExecutionRunDetailsPanel.tsx b/apps/ui/sources/components/sessions/runs/ExecutionRunDetailsPanel.tsx index b575e94f9..4e3b3596b 100644 --- a/apps/ui/sources/components/sessions/runs/ExecutionRunDetailsPanel.tsx +++ b/apps/ui/sources/components/sessions/runs/ExecutionRunDetailsPanel.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import type { ExecutionRunPublicState } from '@happier-dev/protocol'; +import { Text } from '@/components/ui/text/Text'; + export const ExecutionRunDetailsPanel = React.memo((props: Readonly<{ run: ExecutionRunPublicState; diff --git a/apps/ui/sources/components/sessions/runs/ExecutionRunList.test.tsx b/apps/ui/sources/components/sessions/runs/ExecutionRunList.test.tsx index 5c52f8d2d..c006be00c 100644 --- a/apps/ui/sources/components/sessions/runs/ExecutionRunList.test.tsx +++ b/apps/ui/sources/components/sessions/runs/ExecutionRunList.test.tsx @@ -7,16 +7,22 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('react-native', () => ({ View: 'View', Text: 'Text', + Platform: { OS: 'web', select: (options: any) => options?.web ?? options?.default ?? options?.ios ?? null }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); vi.mock('react-native-unistyles', () => ({ useUnistyles: () => ({ theme: { colors: { + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, textSecondary: '#aaa', }, }, }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input({ colors: { shadow: { color: '#000', opacity: 0.2 } } }) : input) }, })); vi.mock('@/text', () => ({ diff --git a/apps/ui/sources/components/sessions/runs/ExecutionRunList.tsx b/apps/ui/sources/components/sessions/runs/ExecutionRunList.tsx index acd651383..29aaba28d 100644 --- a/apps/ui/sources/components/sessions/runs/ExecutionRunList.tsx +++ b/apps/ui/sources/components/sessions/runs/ExecutionRunList.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import { ExecutionRunRow, type ExecutionRunRowRun } from './ExecutionRunRow'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + export const ExecutionRunList = React.memo((props: Readonly<{ runs: readonly ExecutionRunRowRun[]; diff --git a/apps/ui/sources/components/sessions/runs/ExecutionRunRow.tsx b/apps/ui/sources/components/sessions/runs/ExecutionRunRow.tsx index 0e79ee269..1143b78a3 100644 --- a/apps/ui/sources/components/sessions/runs/ExecutionRunRow.tsx +++ b/apps/ui/sources/components/sessions/runs/ExecutionRunRow.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import type { ExecutionRunPublicState } from '@happier-dev/protocol'; import { ExecutionRunStatusPill } from './ExecutionRunStatusPill'; +import { Text } from '@/components/ui/text/Text'; + export type ExecutionRunRowRun = Pick<ExecutionRunPublicState, 'runId' | 'intent' | 'backendId' | 'status' | 'display'> diff --git a/apps/ui/sources/components/sessions/runs/ExecutionRunStatusPill.tsx b/apps/ui/sources/components/sessions/runs/ExecutionRunStatusPill.tsx index 26ef7602a..c2dbe17fe 100644 --- a/apps/ui/sources/components/sessions/runs/ExecutionRunStatusPill.tsx +++ b/apps/ui/sources/components/sessions/runs/ExecutionRunStatusPill.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import type { ExecutionRunStatus } from '@happier-dev/protocol'; +import { Text } from '@/components/ui/text/Text'; + function normalizeStatus(status: unknown): ExecutionRunStatus | 'unknown' { switch (status) { diff --git a/apps/ui/sources/components/sessions/sharing/components/FriendSelector.tsx b/apps/ui/sources/components/sessions/sharing/components/FriendSelector.tsx index abc8b2c2d..a38bc7961 100644 --- a/apps/ui/sources/components/sessions/sharing/components/FriendSelector.tsx +++ b/apps/ui/sources/components/sessions/sharing/components/FriendSelector.tsx @@ -1,5 +1,5 @@ import React, { memo, useMemo, useState } from 'react'; -import { View, Text, TextInput, FlatList, ScrollView, Pressable, useWindowDimensions, Platform, Switch } from 'react-native'; +import { View, FlatList, ScrollView, Pressable, useWindowDimensions, Platform, Switch } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; @@ -13,6 +13,8 @@ import { Typography } from '@/constants/Typography'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; import { Modal } from '@/modal'; import { HappyError } from '@/utils/errors/errors'; +import { Text, TextInput } from '@/components/ui/text/Text'; + /** * Props for FriendSelector component diff --git a/apps/ui/sources/components/sessions/sharing/components/PublicLinkDialog.tsx b/apps/ui/sources/components/sessions/sharing/components/PublicLinkDialog.tsx index 2b122be4e..193b1f3b0 100644 --- a/apps/ui/sources/components/sessions/sharing/components/PublicLinkDialog.tsx +++ b/apps/ui/sources/components/sessions/sharing/components/PublicLinkDialog.tsx @@ -1,5 +1,5 @@ import React, { memo, useEffect, useState } from 'react'; -import { View, Text, Switch, Platform, Linking, useWindowDimensions, ScrollView, Pressable } from 'react-native'; +import { View, Switch, Platform, Linking, useWindowDimensions, ScrollView, Pressable } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; @@ -14,6 +14,8 @@ import { RoundButton } from '@/components/ui/buttons/RoundButton'; import { PublicSessionShare } from '@/sync/domains/social/sharingTypes'; import { HappyError } from '@/utils/errors/errors'; import { QRCode } from '@/components/qr'; +import { Text } from '@/components/ui/text/Text'; + export interface PublicLinkDialogProps { publicShare: PublicSessionShare | null; @@ -277,7 +279,7 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ setIsConfiguring(true); requestAnimationFrame(() => scrollRef.current?.scrollTo({ y: 0, animated: false })); }} - icon={<Ionicons name="refresh-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="refresh-outline" size={29} color={theme.colors.accent.blue} />} /> </ItemGroup> @@ -297,7 +299,7 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ /> <Item title={t('common.copy')} - icon={<Ionicons name="copy-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="copy-outline" size={29} color={theme.colors.accent.blue} />} onPress={handleCopyLink} showChevron={false} showDivider={false} diff --git a/apps/ui/sources/components/sessions/sharing/components/SessionShareDialog.tsx b/apps/ui/sources/components/sessions/sharing/components/SessionShareDialog.tsx index 698e78fb0..3717c1994 100644 --- a/apps/ui/sources/components/sessions/sharing/components/SessionShareDialog.tsx +++ b/apps/ui/sources/components/sessions/sharing/components/SessionShareDialog.tsx @@ -1,5 +1,5 @@ import React, { memo, useCallback, useState } from 'react'; -import { View, Text, Pressable, useWindowDimensions, Switch } from 'react-native'; +import { View, Pressable, useWindowDimensions, Switch } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/ui/lists/Item'; @@ -10,6 +10,8 @@ import { SessionShare, ShareAccessLevel } from '@/sync/domains/social/sharingTyp import { Avatar } from '@/components/ui/avatar/Avatar'; import { BaseModal } from '@/modal/components/BaseModal'; import { Typography } from '@/constants/Typography'; +import { Text } from '@/components/ui/text/Text'; + /** * Props for the SessionShareDialog component @@ -108,12 +110,12 @@ export const SessionShareDialog = memo(function SessionShareDialog({ <ItemGroup> <Item title={t('session.sharing.shareWith')} - icon={<Ionicons name="person-add-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="person-add-outline" size={29} color={theme.colors.accent.blue} />} onPress={onAddShare} /> <Item title={t('session.sharing.publicLink')} - icon={<Ionicons name="link-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="link-outline" size={29} color={theme.colors.accent.blue} />} onPress={onManagePublicLink} showDivider={false} /> @@ -195,7 +197,7 @@ export const SessionShareDialog = memo(function SessionShareDialog({ ) : ( <Item title={t('session.sharing.noShares')} - icon={<Ionicons name="people-outline" size={29} color="#8E8E93" />} + icon={<Ionicons name="people-outline" size={29} color={theme.colors.textSecondary} />} showChevron={false} showDivider={false} /> diff --git a/apps/ui/sources/components/sessions/shell/SessionItem.hoverPinAffordance.test.tsx b/apps/ui/sources/components/sessions/shell/SessionItem.hoverPinAffordance.test.tsx index a71d5ae49..4947cdd87 100644 --- a/apps/ui/sources/components/sessions/shell/SessionItem.hoverPinAffordance.test.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionItem.hoverPinAffordance.test.tsx @@ -22,8 +22,9 @@ vi.mock('react-native', async () => { }; }); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/utils/sessions/sessionUtils', () => ({ @@ -124,6 +125,18 @@ function resolveOpacity(style: unknown): number | null { return null; } +function triggerHoverEnter(node: renderer.ReactTestInstance) { + node.props.onMouseEnter?.(); + node.props.onHoverIn?.(); + node.props.onPointerEnter?.(); +} + +function triggerHoverLeave(node: renderer.ReactTestInstance) { + node.props.onMouseLeave?.(); + node.props.onHoverOut?.(); + node.props.onPointerLeave?.(); +} + describe('SessionItem pin hover affordance (web)', () => { it('hides the pin action promptly after leaving the row', async () => { const { SessionItem } = await import('./SessionItem'); @@ -156,13 +169,13 @@ describe('SessionItem pin hover affordance (web)', () => { expect(resolveOpacity(overlay?.props.style)).toBe(0); await act(async () => { - row.props.onMouseEnter?.(); + triggerHoverEnter(row); }); expect(overlay?.props.pointerEvents).toBe('auto'); expect(resolveOpacity(overlay?.props.style)).toBe(1); await act(async () => { - row.props.onMouseLeave?.(); + triggerHoverLeave(row); }); expect(overlay?.props.pointerEvents).toBe('none'); expect(resolveOpacity(overlay?.props.style)).toBe(0); @@ -197,13 +210,13 @@ describe('SessionItem pin hover affordance (web)', () => { const overlay = pin.parent; await act(async () => { - row.props.onMouseEnter?.(); + triggerHoverEnter(row); }); expect(overlay?.props.pointerEvents).toBe('auto'); expect(resolveOpacity(overlay?.props.style)).toBe(1); await act(async () => { - row.props.onMouseLeave?.(); + triggerHoverLeave(row); }); expect(overlay?.props.pointerEvents).toBe('none'); diff --git a/apps/ui/sources/components/sessions/shell/SessionItem.serverScopeMutation.test.tsx b/apps/ui/sources/components/sessions/shell/SessionItem.serverScopeMutation.test.tsx index faa488f79..4721ce616 100644 --- a/apps/ui/sources/components/sessions/shell/SessionItem.serverScopeMutation.test.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionItem.serverScopeMutation.test.tsx @@ -22,8 +22,9 @@ vi.mock('react-native', async () => { }; }); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/utils/sessions/sessionUtils', () => ({ diff --git a/apps/ui/sources/components/sessions/shell/SessionItem.tags.addNewTag.test.tsx b/apps/ui/sources/components/sessions/shell/SessionItem.tags.addNewTag.test.tsx index 1bbfc398f..0b7b12acd 100644 --- a/apps/ui/sources/components/sessions/shell/SessionItem.tags.addNewTag.test.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionItem.tags.addNewTag.test.tsx @@ -18,8 +18,9 @@ vi.mock('react-native', async () => { }; }); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ @@ -118,6 +119,11 @@ describe('SessionItem tags (new tag)', () => { ); }); + const stableItemLocator = (tree as any).root.findAll((node: any) => { + return node.type === 'Pressable' && node.props?.testID === 'session-list-item-sess_1'; + }); + expect(stableItemLocator).toHaveLength(1); + const dropdown = (tree as any).root.findByType('DropdownMenu'); expect(dropdown.props.emptyLabel).toBe(null); expect(dropdown.props.showCategoryTitles).toBe(false); diff --git a/apps/ui/sources/components/sessions/shell/SessionItem.tags.layout.test.tsx b/apps/ui/sources/components/sessions/shell/SessionItem.tags.layout.test.tsx index e2183af51..0b7775755 100644 --- a/apps/ui/sources/components/sessions/shell/SessionItem.tags.layout.test.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionItem.tags.layout.test.tsx @@ -18,8 +18,9 @@ vi.mock('react-native', async () => { }; }); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ diff --git a/apps/ui/sources/components/sessions/shell/SessionItem.tsx b/apps/ui/sources/components/sessions/shell/SessionItem.tsx index 966df60e4..6065e3d4b 100644 --- a/apps/ui/sources/components/sessions/shell/SessionItem.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionItem.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { Platform, Pressable, View, Text as RNText } from 'react-native'; +import { Platform, Pressable, View } from 'react-native'; import { Swipeable } from 'react-native-gesture-handler'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet } from 'react-native-unistyles'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, Text as RNText } from '@/components/ui/text/Text'; import { Avatar } from '@/components/ui/avatar/Avatar'; import { StatusDot } from '@/components/ui/status/StatusDot'; import { Typography } from '@/constants/Typography'; @@ -306,10 +306,13 @@ const stylesheet = StyleSheet.create((theme) => ({ justifyContent: 'center', backgroundColor: theme.colors.status.error, }, + swipeActionIcon: { + color: theme.colors.button.primary.tint, + }, swipeActionText: { marginTop: 4, fontSize: 12, - color: '#FFFFFF', + color: theme.colors.button.primary.tint, textAlign: 'center', ...Typography.default('semiBold'), }, @@ -385,11 +388,11 @@ export const SessionItem = React.memo( const showTagAction = supportsTag && (Platform.OS !== 'web' || showRowActions); const activeTags = tags ?? []; const knownTags = allKnownTags ?? []; - const handleMouseEnter = React.useCallback(() => { + const handleHoverIn = React.useCallback(() => { setIsHovered(true); }, []); - const handleMouseLeave = React.useCallback(() => { + const handleHoverOut = React.useCallback(() => { setIsHovered(false); }, []); @@ -475,6 +478,7 @@ export const SessionItem = React.memo( const itemContent = ( <Pressable + testID={`session-list-item-${session.id}`} style={[ styles.sessionItem, compact ? styles.sessionItemCompact : null, @@ -482,8 +486,8 @@ export const SessionItem = React.memo( selected ? styles.sessionItemSelected : null, embedded && !embeddedIsLast ? styles.embeddedSeparator : null, ]} - onMouseEnter={Platform.OS === 'web' ? handleMouseEnter : undefined} - onMouseLeave={Platform.OS === 'web' ? handleMouseLeave : undefined} + onHoverIn={Platform.OS === 'web' ? handleHoverIn : undefined} + onHoverOut={Platform.OS === 'web' ? handleHoverOut : undefined} onPressIn={() => { if (isTablet) { navigateToSession(session.id, serverId ? { serverId } : undefined); @@ -702,7 +706,7 @@ export const SessionItem = React.memo( const renderRightActions = () => ( <Pressable style={styles.swipeAction} onPress={handleSwipeAction} disabled={mutatingSession}> - <Ionicons name={isActiveSession ? 'stop-circle-outline' : 'archive-outline'} size={20} color="#FFFFFF" /> + <Ionicons name={isActiveSession ? 'stop-circle-outline' : 'archive-outline'} size={20} style={styles.swipeActionIcon} /> <Text style={styles.swipeActionText} numberOfLines={2}> {isActiveSession ? t('sessionInfo.stopSession') : t('sessionInfo.archiveSession')} </Text> diff --git a/apps/ui/sources/components/sessions/shell/SessionView.tsx b/apps/ui/sources/components/sessions/shell/SessionView.tsx index d15230d79..50cc28479 100644 --- a/apps/ui/sources/components/sessions/shell/SessionView.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionView.tsx @@ -17,6 +17,7 @@ import { useFeatureEnabled } from '@/hooks/server/useFeatureEnabled'; import { scmStatusSync } from '@/scm/scmStatusSync'; import { continueSessionWithReplay, sessionAbort, resumeSession } from '@/sync/ops'; import { storage, useAutomations, useIsDataReady, useLocalSetting, useMachine, useRealtimeStatus, useSessionMessages, useSessionPendingMessages, useSessionReviewCommentsDrafts, useSessionUsage, useSetting, useSettings } from '@/sync/domains/state/storage'; +import { setActiveViewingSessionId, clearActiveViewingSessionId } from '@/sync/domains/session/activeViewingSession'; import { canResumeSessionWithOptions, getAgentVendorResumeId } from '@/agents/runtime/resumeCapabilities'; import { DEFAULT_AGENT_ID, getAgentCore, resolveAgentIdFromFlavor, buildResumeSessionExtrasFromUiState, getAgentResumeExperimentsFromSettings, getResumePreflightIssues, getResumePreflightPrefetchPlan } from '@/agents/catalog/catalog'; import { useResumeCapabilityOptions } from '@/agents/hooks/useResumeCapabilityOptions'; @@ -55,7 +56,7 @@ import { useFocusEffect } from '@react-navigation/native'; import { useRouter } from 'expo-router'; import * as React from 'react'; import { useMemo } from 'react'; -import { ActivityIndicator, Platform, Pressable, Text, View } from 'react-native'; +import { ActivityIndicator, Platform, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useUnistyles } from 'react-native-unistyles'; import { sessionSwitch } from '@/sync/ops'; @@ -69,6 +70,8 @@ import { executeSessionComposerResolution } from '@/sync/domains/input/slashComm import { useAttachmentsUploadConfig } from '@/components/sessions/attachments/useAttachmentsUploadConfig'; import { useAttachmentDraftManager } from '@/components/sessions/attachments/useAttachmentDraftManager'; import { formatAttachmentsBlock, uploadAttachmentDraftsToSession } from '@/components/sessions/attachments/uploadAttachmentDraftsToSession'; +import { Text } from '@/components/ui/text/Text'; + function formatResumeSupportDetailCode(code: 'cliNotDetected' | 'capabilityProbeFailed' | 'acpProbeFailed' | 'loadSessionFalse'): string { switch (code) { @@ -431,6 +434,7 @@ function SessionViewLoaded({ sessionId, session, jumpToSeq }: { sessionId: strin useFocusEffect(React.useCallback(() => { isFocusedRef.current = true; + setActiveViewingSessionId(sessionId); { const current = storage.getState().sessions[sessionId]; lastMarkedRef.current = { @@ -440,6 +444,7 @@ function SessionViewLoaded({ sessionId, session, jumpToSeq }: { sessionId: strin markSessionViewed(); return () => { isFocusedRef.current = false; + clearActiveViewingSessionId(sessionId); if (markViewedTimeoutRef.current) { clearTimeout(markViewedTimeoutRef.current); markViewedTimeoutRef.current = null; @@ -1221,29 +1226,31 @@ function SessionViewLoaded({ sessionId, session, jumpToSeq }: { sessionId: strin position: 'absolute', top: 8, // Position at top of content area (padding handled by parent) alignSelf: 'center', - backgroundColor: '#FFF3CD', + backgroundColor: theme.colors.box.warning.background, + borderWidth: 1, + borderColor: theme.colors.box.warning.border, borderRadius: 100, // Fully rounded pill paddingHorizontal: 14, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', zIndex: 998, // Below voice bar but above content - shadowColor: '#000', + shadowColor: theme.colors.shadow.color, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4, }} > - <Ionicons name="warning-outline" size={14} color="#FF9500" style={{ marginRight: 6 }} /> + <Ionicons name="warning-outline" size={14} color={theme.colors.box.warning.text} style={{ marginRight: 6 }} /> <Text style={{ fontSize: 12, - color: '#856404', + color: theme.colors.box.warning.text, fontWeight: '600' }}> {t('sessionInfo.cliVersionOutdated')} </Text> - <Ionicons name="close" size={14} color="#856404" style={{ marginLeft: 8 }} /> + <Ionicons name="close" size={14} color={theme.colors.box.warning.text} style={{ marginLeft: 8 }} /> </Pressable> )} @@ -1288,7 +1295,7 @@ function SessionViewLoaded({ sessionId, session, jumpToSeq }: { sessionId: strin <Ionicons name={Platform.OS === 'ios' ? 'chevron-back' : 'arrow-back'} size={Platform.select({ ios: 28, default: 24 })} - color="#000" + color={theme.colors.text} /> </Pressable> ) diff --git a/apps/ui/sources/components/sessions/shell/SessionsList.pinningAndReorder.test.tsx b/apps/ui/sources/components/sessions/shell/SessionsList.pinningAndReorder.test.tsx index 3c00dc0f3..6f9f79237 100644 --- a/apps/ui/sources/components/sessions/shell/SessionsList.pinningAndReorder.test.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionsList.pinningAndReorder.test.tsx @@ -79,8 +79,9 @@ vi.mock('@/components/ui/feedback/UpdateBanner', () => ({ UpdateBanner: 'UpdateBanner', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/utils/sessions/sessionUtils', () => ({ diff --git a/apps/ui/sources/components/sessions/shell/SessionsList.sessionItem.serverId.test.tsx b/apps/ui/sources/components/sessions/shell/SessionsList.sessionItem.serverId.test.tsx index ec6712b3d..37ef30f28 100644 --- a/apps/ui/sources/components/sessions/shell/SessionsList.sessionItem.serverId.test.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionsList.sessionItem.serverId.test.tsx @@ -24,8 +24,9 @@ vi.mock('react-native', async () => { }; }); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/utils/sessions/sessionUtils', () => ({ diff --git a/apps/ui/sources/components/sessions/shell/SessionsList.tsx b/apps/ui/sources/components/sessions/shell/SessionsList.tsx index ea6248953..2458f7956 100644 --- a/apps/ui/sources/components/sessions/shell/SessionsList.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionsList.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, FlatList, Pressable } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { usePathname, useRouter } from 'expo-router'; import { SessionListViewItem, useSetting, useSettingMutable } from '@/sync/domains/state/storage'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; diff --git a/apps/ui/sources/components/sessions/sourceControl/states/NotSourceControlRepositoryState.tsx b/apps/ui/sources/components/sessions/sourceControl/states/NotSourceControlRepositoryState.tsx index 08cd68c7f..d2d00064e 100644 --- a/apps/ui/sources/components/sessions/sourceControl/states/NotSourceControlRepositoryState.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/states/NotSourceControlRepositoryState.tsx @@ -3,7 +3,7 @@ import { View } from 'react-native'; import { Octicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; diff --git a/apps/ui/sources/components/sessions/sourceControl/states/SourceControlSessionInactiveState.tsx b/apps/ui/sources/components/sessions/sourceControl/states/SourceControlSessionInactiveState.tsx index 9ea289370..1a4370870 100644 --- a/apps/ui/sources/components/sessions/sourceControl/states/SourceControlSessionInactiveState.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/states/SourceControlSessionInactiveState.tsx @@ -4,7 +4,7 @@ import { Octicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; diff --git a/apps/ui/sources/components/sessions/sourceControl/states/SourceControlUnavailableState.test.tsx b/apps/ui/sources/components/sessions/sourceControl/states/SourceControlUnavailableState.test.tsx index 17b6102b3..b3e8eee2a 100644 --- a/apps/ui/sources/components/sessions/sourceControl/states/SourceControlUnavailableState.test.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/states/SourceControlUnavailableState.test.tsx @@ -28,8 +28,9 @@ vi.mock('@/components/ui/buttons/RoundButton', () => ({ RoundButton: (props: any) => React.createElement('RoundButton', props), })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: (props: any) => React.createElement('Text', props, props.children), + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/components/sessions/sourceControl/states/SourceControlUnavailableState.tsx b/apps/ui/sources/components/sessions/sourceControl/states/SourceControlUnavailableState.tsx index 7fc48b583..9e93a141d 100644 --- a/apps/ui/sources/components/sessions/sourceControl/states/SourceControlUnavailableState.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/states/SourceControlUnavailableState.tsx @@ -4,7 +4,7 @@ import { Octicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { RPC_ERROR_MESSAGES } from '@happier-dev/protocol/rpc'; diff --git a/apps/ui/sources/components/sessions/sourceControl/status/CompactSourceControlStatus.test.tsx b/apps/ui/sources/components/sessions/sourceControl/status/CompactSourceControlStatus.test.tsx index 496c432c6..d43c375a4 100644 --- a/apps/ui/sources/components/sessions/sourceControl/status/CompactSourceControlStatus.test.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/status/CompactSourceControlStatus.test.tsx @@ -13,22 +13,8 @@ vi.mock('@/sync/domains/state/storage', () => ({ vi.mock('react-native', () => ({ View: 'View', Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { - create: (value: any) => - typeof value === 'function' - ? value({ - colors: { - surfaceHighest: '#222', - textSecondary: '#999', - gitAddedText: '#0f0', - gitRemovedText: '#f00', - }, - }) - : value, - }, + Platform: { OS: 'web', select: (options: any) => options?.web ?? options?.default ?? options?.ios ?? null }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); vi.mock('@expo/vector-icons', () => ({ diff --git a/apps/ui/sources/components/sessions/sourceControl/status/CompactSourceControlStatus.tsx b/apps/ui/sources/components/sessions/sourceControl/status/CompactSourceControlStatus.tsx index 878074e6d..a48b6501b 100644 --- a/apps/ui/sources/components/sessions/sourceControl/status/CompactSourceControlStatus.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/status/CompactSourceControlStatus.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { useSessionProjectScmSnapshot } from '@/sync/domains/state/storage'; import { StyleSheet } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; import { buildScmStatusSummaryFromSnapshot } from './statusSummary'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme) => ({ container: { diff --git a/apps/ui/sources/components/sessions/sourceControl/status/ProjectSourceControlStatus.test.tsx b/apps/ui/sources/components/sessions/sourceControl/status/ProjectSourceControlStatus.test.tsx index 12e0b1211..198a06d4a 100644 --- a/apps/ui/sources/components/sessions/sourceControl/status/ProjectSourceControlStatus.test.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/status/ProjectSourceControlStatus.test.tsx @@ -13,22 +13,8 @@ vi.mock('@/sync/domains/state/storage', () => ({ vi.mock('react-native', () => ({ View: 'View', Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { - create: (value: any) => - typeof value === 'function' - ? value({ - colors: { - groupped: { sectionTitle: '#aaa' }, - textSecondary: '#999', - gitAddedText: '#0f0', - gitRemovedText: '#f00', - }, - }) - : value, - }, + Platform: { OS: 'web', select: (options: any) => options?.web ?? options?.default ?? options?.ios ?? null }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); describe('ProjectSourceControlStatus', () => { diff --git a/apps/ui/sources/components/sessions/sourceControl/status/ProjectSourceControlStatus.tsx b/apps/ui/sources/components/sessions/sourceControl/status/ProjectSourceControlStatus.tsx index 0bf9ea71a..d7fae19d4 100644 --- a/apps/ui/sources/components/sessions/sourceControl/status/ProjectSourceControlStatus.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/status/ProjectSourceControlStatus.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { useSessionProjectScmSnapshot } from '@/sync/domains/state/storage'; import { StyleSheet } from 'react-native-unistyles'; import { buildScmStatusSummaryFromSnapshot } from './statusSummary'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme) => ({ container: { diff --git a/apps/ui/sources/components/sessions/sourceControl/status/SourceControlStatusBadge.test.tsx b/apps/ui/sources/components/sessions/sourceControl/status/SourceControlStatusBadge.test.tsx index 350ff8872..fb15942ff 100644 --- a/apps/ui/sources/components/sessions/sourceControl/status/SourceControlStatusBadge.test.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/status/SourceControlStatusBadge.test.tsx @@ -10,10 +10,7 @@ vi.mock('@/sync/domains/state/storage', () => ({ useSessionProjectScmSnapshot: () => snapshotMock, })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); +vi.mock('react-native', async () => await import('@/dev/reactNativeStub')); vi.mock('react-native-unistyles', () => ({ useUnistyles: () => ({ @@ -22,9 +19,25 @@ vi.mock('react-native-unistyles', () => ({ button: { secondary: { tint: '#999' } }, gitAddedText: '#0f0', gitRemovedText: '#f00', + shadow: { color: '#000', opacity: 0.1 }, }, }, }), + StyleSheet: { + create: (input: any) => + typeof input === 'function' + ? input({ + colors: { + button: { secondary: { tint: '#999' } }, + gitAddedText: '#0f0', + gitRemovedText: '#f00', + shadow: { color: '#000', opacity: 0.1 }, + }, + }) + : input, + configure: () => {}, + absoluteFillObject: {}, + }, })); vi.mock('@expo/vector-icons', () => ({ diff --git a/apps/ui/sources/components/sessions/sourceControl/status/SourceControlStatusBadge.tsx b/apps/ui/sources/components/sessions/sourceControl/status/SourceControlStatusBadge.tsx index d625e4418..e308bca97 100644 --- a/apps/ui/sources/components/sessions/sourceControl/status/SourceControlStatusBadge.tsx +++ b/apps/ui/sources/components/sessions/sourceControl/status/SourceControlStatusBadge.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { Octicons } from '@expo/vector-icons'; import { useSessionProjectScmSnapshot } from '@/sync/domains/state/storage'; import { useUnistyles } from 'react-native-unistyles'; import { buildScmStatusSummaryFromSnapshot } from './statusSummary'; +import { Text } from '@/components/ui/text/Text'; + // Custom hook to check if a source-control status badge should be shown. export function useHasMeaningfulScmStatus(sessionId: string): boolean { diff --git a/apps/ui/sources/components/sessions/transcript/ChatFooter.localControl.test.tsx b/apps/ui/sources/components/sessions/transcript/ChatFooter.localControl.test.tsx index 98422d015..5a0ed67dc 100644 --- a/apps/ui/sources/components/sessions/transcript/ChatFooter.localControl.test.tsx +++ b/apps/ui/sources/components/sessions/transcript/ChatFooter.localControl.test.tsx @@ -13,6 +13,8 @@ vi.mock('react-native', () => ({ View: 'View', Text: 'Text', Pressable: 'Pressable', + Platform: { OS: 'web', select: (options: any) => options?.web ?? options?.default ?? options?.ios ?? null }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); vi.mock('@expo/vector-icons', () => ({ @@ -23,10 +25,14 @@ vi.mock('react-native-unistyles', () => ({ useUnistyles: () => ({ theme: { colors: { + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, box: { warning: { background: '#fff3cd', text: '#856404' } }, }, }, }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input({ colors: { shadow: { color: '#000', opacity: 0.2 } } }) : input) }, })); vi.mock('@/constants/Typography', () => ({ @@ -60,6 +66,14 @@ describe('ChatFooter (local control)', () => { onRequestSwitchToRemote: vi.fn(), }); + // Root container should allow full-width children so long notices wrap instead of overflowing. + const views = tree.root.findAllByType('View'); + expect(views[0]?.props?.style?.alignItems).toBe('stretch'); + + const warningViews = views.filter((v) => v.props?.style?.backgroundColor === '#fff3cd'); + expect(warningViews.length).toBe(1); + expect(warningViews[0].props.style.flexWrap).toBe('wrap'); + const pressables = tree.root.findAllByType('Pressable'); expect(pressables.length).toBeGreaterThan(0); expect(pressables.some((node) => node.props.accessibilityLabel === 'chatFooter.switchToRemote')).toBe(true); diff --git a/apps/ui/sources/components/sessions/transcript/ChatFooter.tsx b/apps/ui/sources/components/sessions/transcript/ChatFooter.tsx index 9b89ac15f..54f409a13 100644 --- a/apps/ui/sources/components/sessions/transcript/ChatFooter.tsx +++ b/apps/ui/sources/components/sessions/transcript/ChatFooter.tsx @@ -1,11 +1,13 @@ import * as React from 'react'; -import { View, Text, ViewStyle, TextStyle, Pressable } from 'react-native'; +import { View, ViewStyle, TextStyle, Pressable } from 'react-native'; import { Typography } from '@/constants/Typography'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { SessionNoticeBanner, type SessionNoticeBannerProps } from '@/components/sessions/SessionNoticeBanner'; import { layout } from '@/components/ui/layout/layout'; +import { Text } from '@/components/ui/text/Text'; + interface ChatFooterProps { controlledByUser?: boolean; @@ -17,27 +19,31 @@ interface ChatFooterProps { export const ChatFooter = React.memo((props: ChatFooterProps) => { const { theme } = useUnistyles(); const containerStyle: ViewStyle = { - alignItems: 'center', + // Allow children to take full width so long banners can wrap instead of overflowing + alignItems: 'stretch', paddingTop: 4, paddingBottom: 2, }; const warningContainerStyle: ViewStyle = { flexDirection: 'row', alignItems: 'center', + flexWrap: 'wrap', paddingHorizontal: 12, - paddingVertical: 4, + paddingVertical: 8, backgroundColor: theme.colors.box.warning.background, borderRadius: 8, - marginHorizontal: 32, marginTop: 4, + marginHorizontal: 8, }; const warningTextStyle: TextStyle = { + flex: 1, fontSize: 12, color: theme.colors.box.warning.text, marginLeft: 6, ...Typography.default() }; const switchButtonStyle: ViewStyle = { + flexShrink: 0, marginLeft: 10, paddingHorizontal: 8, paddingVertical: 4, @@ -54,24 +60,32 @@ export const ChatFooter = React.memo((props: ChatFooterProps) => { return ( <View style={containerStyle}> {props.controlledByUser && ( - <View style={warningContainerStyle}> - <Ionicons - name="information-circle" - size={16} - color={theme.colors.box.warning.text} - /> - <Text style={warningTextStyle}> - {t(props.permissionsInUiWhileLocal ? 'chatFooter.sessionRunningLocally' : 'chatFooter.permissionsTerminalOnly')} - </Text> - {props.onRequestSwitchToRemote && ( - <Pressable - accessibilityLabel={t('chatFooter.switchToRemote')} - onPress={props.onRequestSwitchToRemote} - style={switchButtonStyle} - > - <Text style={switchButtonTextStyle}>{t('chatFooter.switchToRemote')}</Text> - </Pressable> - )} + <View style={{ width: '100%', flexDirection: 'row', justifyContent: 'center' }}> + <View style={{ width: '100%', flexGrow: 1, flexBasis: 0, maxWidth: layout.maxWidth }}> + <View style={warningContainerStyle}> + <Ionicons + name="information-circle" + size={16} + color={theme.colors.box.warning.text} + /> + <Text style={warningTextStyle}> + {t( + props.permissionsInUiWhileLocal + ? 'chatFooter.sessionRunningLocally' + : 'chatFooter.permissionsTerminalOnly' + )} + </Text> + {props.onRequestSwitchToRemote && ( + <Pressable + accessibilityLabel={t('chatFooter.switchToRemote')} + onPress={props.onRequestSwitchToRemote} + style={switchButtonStyle} + > + <Text style={switchButtonTextStyle}>{t('chatFooter.switchToRemote')}</Text> + </Pressable> + )} + </View> + </View> </View> )} {props.notice && ( diff --git a/apps/ui/sources/components/sessions/transcript/ChatHeaderView.test.tsx b/apps/ui/sources/components/sessions/transcript/ChatHeaderView.test.tsx index f95b5277d..6e8477e9a 100644 --- a/apps/ui/sources/components/sessions/transcript/ChatHeaderView.test.tsx +++ b/apps/ui/sources/components/sessions/transcript/ChatHeaderView.test.tsx @@ -36,6 +36,9 @@ vi.mock('react-native-unistyles', () => ({ theme: { colors: { header: { background: '#fff', tint: '#111' }, + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, }, }, }), @@ -44,6 +47,9 @@ vi.mock('react-native-unistyles', () => ({ const theme = { colors: { header: { background: '#fff', tint: '#111' }, + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, }, }; return typeof input === 'function' ? input(theme, {}) : input; diff --git a/apps/ui/sources/components/sessions/transcript/ChatHeaderView.tsx b/apps/ui/sources/components/sessions/transcript/ChatHeaderView.tsx index 872a54793..548e7c5f7 100644 --- a/apps/ui/sources/components/sessions/transcript/ChatHeaderView.tsx +++ b/apps/ui/sources/components/sessions/transcript/ChatHeaderView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, Platform, Pressable } from 'react-native'; +import { View, Platform, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; @@ -8,6 +8,8 @@ import { Typography } from '@/constants/Typography'; import { useHeaderHeight } from '@/utils/platform/responsive'; import { layout } from '@/components/ui/layout/layout'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + interface ChatHeaderViewProps { title: string; diff --git a/apps/ui/sources/components/sessions/transcript/ChatList.initialScrollBehavior.test.tsx b/apps/ui/sources/components/sessions/transcript/ChatList.initialScrollBehavior.test.tsx index 2dabcccda..d2fc9fe88 100644 --- a/apps/ui/sources/components/sessions/transcript/ChatList.initialScrollBehavior.test.tsx +++ b/apps/ui/sources/components/sessions/transcript/ChatList.initialScrollBehavior.test.tsx @@ -17,7 +17,7 @@ let sessionPendingState: { messages: any[] } = { messages: [] }; let sessionActionDraftsState: any[] = []; let sessionState: any = null; -const buildChatListItemsMock = vi.fn(() => []); +const buildChatListItemsMock = vi.fn((..._args: any[]) => []); vi.mock('react-native', async (importOriginal) => { const ReactMod = await import('react'); @@ -53,7 +53,7 @@ vi.mock('@/sync/domains/state/storage', () => ({ })); vi.mock('@/components/sessions/chatListItems', () => ({ - buildChatListItems: (...args: any[]) => buildChatListItemsMock(...args), + buildChatListItems: buildChatListItemsMock, })); vi.mock('./ChatFooter', () => ({ @@ -82,7 +82,7 @@ vi.mock('@/utils/system/fireAndForget', () => ({ vi.mock('@/sync/sync', () => ({ sync: { - loadOlderMessages: (...args: any[]) => loadOlderMessagesMock(...args), + loadOlderMessages: loadOlderMessagesMock, }, })); diff --git a/apps/ui/sources/components/sessions/transcript/ChatList.nullSession.test.tsx b/apps/ui/sources/components/sessions/transcript/ChatList.nullSession.test.tsx index 8c2f84041..9cd767184 100644 --- a/apps/ui/sources/components/sessions/transcript/ChatList.nullSession.test.tsx +++ b/apps/ui/sources/components/sessions/transcript/ChatList.nullSession.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import renderer from 'react-test-renderer'; +import renderer, { act } from 'react-test-renderer'; import { describe, it, expect, vi } from 'vitest'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -58,6 +58,20 @@ describe('ChatList', () => { canApprovePermissions: true, } as any; - expect(() => renderer.create(<ChatList session={session} />)).not.toThrow(); + let tree: renderer.ReactTestRenderer | undefined; + let thrown: unknown; + try { + await act(async () => { + tree = renderer.create(<ChatList session={session} />); + }); + } catch (error) { + thrown = error; + } finally { + act(() => { + tree?.unmount(); + }); + } + + expect(thrown).toBeUndefined(); }); }); diff --git a/apps/ui/sources/components/sessions/transcript/CommandView.tsx b/apps/ui/sources/components/sessions/transcript/CommandView.tsx index 46cc48a8d..bd6a9c3de 100644 --- a/apps/ui/sources/components/sessions/transcript/CommandView.tsx +++ b/apps/ui/sources/components/sessions/transcript/CommandView.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; -import { Text, View, Platform } from 'react-native'; +import { View, Platform } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + interface CommandViewProps { command: string; diff --git a/apps/ui/sources/components/sessions/transcript/MessageView.tsx b/apps/ui/sources/components/sessions/transcript/MessageView.tsx index fd6bf254e..dc647eca8 100644 --- a/apps/ui/sources/components/sessions/transcript/MessageView.tsx +++ b/apps/ui/sources/components/sessions/transcript/MessageView.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { View, Text, Pressable, Platform } from "react-native"; +import { View, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; @@ -21,6 +21,8 @@ import { useRouter } from 'expo-router'; import { buildSessionFileDeepLink } from '@/utils/url/sessionFileDeepLink'; import { fireAndForget } from '@/utils/system/fireAndForget'; import { useSetting } from '@/sync/domains/state/storage'; +import { Text } from '@/components/ui/text/Text'; + export const MessageView = (props: { message: Message; diff --git a/apps/ui/sources/components/settings/SettingsView.multiServerMachines.test.tsx b/apps/ui/sources/components/settings/SettingsView.multiServerMachines.test.tsx index ec117c92a..33e5e548d 100644 --- a/apps/ui/sources/components/settings/SettingsView.multiServerMachines.test.tsx +++ b/apps/ui/sources/components/settings/SettingsView.multiServerMachines.test.tsx @@ -27,8 +27,9 @@ vi.mock('expo-image', () => ({ Image: 'Image', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'StyledText', + TextInput: 'TextInput', })); vi.mock('expo-router', () => ({ @@ -97,21 +98,6 @@ vi.mock('@/utils/sessions/machineUtils', () => ({ isMachineOnline: () => false, })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - dark: false, - colors: { - surface: '#ffffff', - text: '#111111', - textSecondary: '#666666', - status: { connected: '#00ff00', disconnected: '#ff0000' }, - groupped: { background: '#fff', sectionTitle: '#666' }, - }, - }, - }), -})); - vi.mock('@/components/ui/layout/layout', () => ({ layout: { maxWidth: 1000 }, })); diff --git a/apps/ui/sources/components/settings/SettingsView.runsEntry.test.tsx b/apps/ui/sources/components/settings/SettingsView.runsEntry.test.tsx index 696db5eac..57eefca49 100644 --- a/apps/ui/sources/components/settings/SettingsView.runsEntry.test.tsx +++ b/apps/ui/sources/components/settings/SettingsView.runsEntry.test.tsx @@ -24,8 +24,9 @@ vi.mock('expo-image', () => ({ Image: 'Image', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'StyledText', + TextInput: 'TextInput', })); vi.mock('expo-router', () => ({ @@ -115,21 +116,6 @@ vi.mock('@/hooks/ui/useMultiClick', () => ({ useMultiClick: (cb: () => void) => cb, })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - dark: false, - colors: { - surface: '#ffffff', - text: '#111111', - textSecondary: '#666666', - status: { connected: '#00ff00', disconnected: '#ff0000' }, - groupped: { background: '#fff', sectionTitle: '#666' }, - }, - }, - }), -})); - vi.mock('@/components/ui/layout/layout', () => ({ layout: { maxWidth: 1000 }, })); diff --git a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx index ad2467904..9c5912f11 100644 --- a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx +++ b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx @@ -24,8 +24,9 @@ vi.mock('expo-image', () => ({ Image: 'Image', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'StyledText', + TextInput: 'TextInput', })); vi.mock('expo-router', () => ({ @@ -119,21 +120,6 @@ vi.mock('@/utils/sessions/machineUtils', () => ({ isMachineOnline: () => false, })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - dark: false, - colors: { - surface: '#ffffff', - text: '#111111', - textSecondary: '#666666', - status: { connected: '#00ff00', disconnected: '#ff0000' }, - groupped: { background: '#fff', sectionTitle: '#666' }, - }, - }, - }), -})); - vi.mock('@/components/ui/layout/layout', () => ({ layout: { maxWidth: 1000 }, })); diff --git a/apps/ui/sources/components/settings/SettingsView.tsx b/apps/ui/sources/components/settings/SettingsView.tsx index 6dd131e6c..d722a54ff 100644 --- a/apps/ui/sources/components/settings/SettingsView.tsx +++ b/apps/ui/sources/components/settings/SettingsView.tsx @@ -1,7 +1,7 @@ -import { View, Pressable, Platform, Linking, Text as RNText, ActivityIndicator } from 'react-native'; +import { View, Pressable, Platform, Linking, ActivityIndicator } from 'react-native'; import { Image } from 'expo-image'; import * as React from 'react'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, Text as RNText } from '@/components/ui/text/Text'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect } from '@react-navigation/native'; @@ -263,14 +263,14 @@ export const SettingsView = React.memo(function SettingsView() { <ItemGroup> <Item title={t('settings.scanQrCodeToAuthenticate')} - icon={<Ionicons name="qr-code-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="qr-code-outline" size={29} color={theme.colors.accent.blue} />} onPress={connectTerminal} loading={isLoading} showChevron={false} /> <Item title={t('connect.enterUrlManually')} - icon={<Ionicons name="link-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="link-outline" size={29} color={theme.colors.accent.blue} />} onPress={async () => { const url = await Modal.prompt( t('modals.authenticateTerminal'), @@ -297,7 +297,7 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('settings.supportUs')} subtitle={isPro ? t('settings.supportUsSubtitlePro') : t('settings.supportUsSubtitle')} - icon={<Ionicons name="heart" size={29} color="#FF3B30" />} + icon={<Ionicons name="heart" size={29} color={theme.colors.warningCritical} />} showChevron={false} onPress={handleSupportUs} /> @@ -331,7 +331,7 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('navigation.friends')} subtitle={t('friends.manageFriends')} - icon={<Ionicons name="people-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="people-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push('/friends')} /> </ItemGroup> */} @@ -362,26 +362,26 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('settings.account')} subtitle={t('settings.accountSubtitle')} - icon={<Ionicons name="person-circle-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="person-circle-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push('/(app)/settings/account')} /> <Item title={t('settings.appearance')} subtitle={t('settings.appearanceSubtitle')} - icon={<Ionicons name="color-palette-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="color-palette-outline" size={29} color={theme.colors.accent.indigo} />} onPress={() => router.push('/(app)/settings/appearance')} /> <Item title="Notifications" subtitle="Push notification preferences" - icon={<Ionicons name="notifications-outline" size={29} color="#0A84FF" />} + icon={<Ionicons name="notifications-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push('/(app)/settings/notifications')} /> {voiceEnabled ? ( <Item title={t('settings.voiceAssistant')} subtitle={t('settings.voiceAssistantSubtitle')} - icon={<Ionicons name="mic-outline" size={29} color="#34C759" />} + icon={<Ionicons name="mic-outline" size={29} color={theme.colors.success} />} onPress={() => router.push('/(app)/settings/voice')} /> ) : null} @@ -389,41 +389,41 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('settings.memorySearch')} subtitle={t('settings.memorySearchSubtitle')} - icon={<Ionicons name="search-outline" size={29} color="#34C759" />} + icon={<Ionicons name="search-outline" size={29} color={theme.colors.success} />} onPress={() => router.push('/(app)/settings/memory')} /> ) : null} <Item title={t('settings.featuresTitle')} subtitle={t('settings.featuresSubtitle')} - icon={<Ionicons name="flask-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="flask-outline" size={29} color={theme.colors.accent.orange} />} onPress={() => router.push('/(app)/settings/features')} /> <Item title={t('settings.session')} subtitle={terminalUseTmux ? t('settings.sessionSubtitleTmuxEnabled') : t('settings.sessionSubtitleMessageSendingAndTmux')} - icon={<Ionicons name="terminal-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="terminal-outline" size={29} color={theme.colors.accent.indigo} />} onPress={() => router.push('/(app)/settings/session')} /> {attachmentsUploadsEnabled ? ( <Item title="Attachments" subtitle="File upload preferences" - icon={<Ionicons name="attach-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="attach-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push('/(app)/settings/attachments')} /> ) : null} <Item title={t('settings.servers')} subtitle={t('settings.serversSubtitle')} - icon={<Ionicons name="server-outline" size={29} color="#0A84FF" />} + icon={<Ionicons name="server-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push('/server')} /> {sourceControlEnabled ? ( <Item title="Source control" subtitle="Commit strategy and backend behavior" - icon={<Ionicons name="git-branch-outline" size={29} color="#34C759" />} + icon={<Ionicons name="git-branch-outline" size={29} color={theme.colors.success} />} onPress={() => router.push('/(app)/settings/source-control')} /> ) : null} @@ -431,7 +431,7 @@ export const SettingsView = React.memo(function SettingsView() { <Item title="Automations" subtitle="Manage scheduled sessions and recurring runs" - icon={<Ionicons name="timer-outline" size={29} color="#0A84FF" />} + icon={<Ionicons name="timer-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push('/automations')} /> ) : null} @@ -439,21 +439,21 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('runs.title') ?? 'Runs'} subtitle="Execution runs across machines" - icon={<Ionicons name="play-outline" size={29} color="#34C759" />} + icon={<Ionicons name="play-outline" size={29} color={theme.colors.success} />} onPress={() => router.push('/runs')} /> ) : null} <Item title={t('settingsProviders.title')} subtitle={t('settingsProviders.entrySubtitle')} - icon={<Ionicons name="sparkles-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="sparkles-outline" size={29} color={theme.colors.accent.orange} />} onPress={() => router.push('/(app)/settings/providers')} /> {connectedServicesEnabled ? ( <Item title={'Connected services'} subtitle={'Claude/Codex subscriptions and OAuth profiles'} - icon={<Ionicons name="key-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="key-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push('/(app)/settings/connected-services')} /> ) : null} @@ -461,7 +461,7 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('settings.profiles')} subtitle={t('settings.profilesSubtitle')} - icon={<Ionicons name="person-outline" size={29} color="#AF52DE" />} + icon={<Ionicons name="person-outline" size={29} color={theme.colors.accent.purple} />} onPress={() => router.push('/(app)/settings/profiles')} /> )} @@ -469,7 +469,7 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('settings.secrets')} subtitle={t('settings.secretsSubtitle')} - icon={<Ionicons name="key-outline" size={29} color="#AF52DE" />} + icon={<Ionicons name="key-outline" size={29} color={theme.colors.accent.purple} />} onPress={() => router.push('/(app)/settings/secrets')} /> )} @@ -477,7 +477,7 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('settings.usage')} subtitle={t('settings.usageSubtitle')} - icon={<Ionicons name="analytics-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="analytics-outline" size={29} color={theme.colors.accent.blue} />} onPress={() => router.push('/(app)/settings/usage')} /> )} @@ -488,7 +488,7 @@ export const SettingsView = React.memo(function SettingsView() { <ItemGroup title={t('settings.developer')}> <Item title={t('settings.developerTools')} - icon={<Ionicons name="construct-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="construct-outline" size={29} color={theme.colors.accent.indigo} />} onPress={() => router.push('/(app)/dev')} /> </ItemGroup> @@ -500,7 +500,7 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('settings.whatsNew')} subtitle={t('settings.whatsNewSubtitle')} - icon={<Ionicons name="sparkles-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="sparkles-outline" size={29} color={theme.colors.accent.orange} />} onPress={() => { trackWhatsNewClicked(); router.push('/(app)/changelog'); @@ -515,12 +515,12 @@ export const SettingsView = React.memo(function SettingsView() { /> <Item title={t('settings.reportIssue')} - icon={<Ionicons name="bug-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="bug-outline" size={29} color={theme.colors.warningCritical} />} onPress={handleReportIssue} /> <Item title={t('settings.privacyPolicy')} - icon={<Ionicons name="shield-checkmark-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="shield-checkmark-outline" size={29} color={theme.colors.accent.blue} />} onPress={async () => { const url = 'https://docs.happier.dev/legal/privacy'; const supported = await Linking.canOpenURL(url); @@ -531,7 +531,7 @@ export const SettingsView = React.memo(function SettingsView() { /> <Item title={t('settings.termsOfService')} - icon={<Ionicons name="document-text-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="document-text-outline" size={29} color={theme.colors.accent.blue} />} onPress={async () => { const url = 'https://docs.happier.dev/legal/terms'; const supported = await Linking.canOpenURL(url); @@ -543,7 +543,7 @@ export const SettingsView = React.memo(function SettingsView() { {Platform.OS === 'ios' && ( <Item title={t('settings.eula')} - icon={<Ionicons name="document-text-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="document-text-outline" size={29} color={theme.colors.accent.blue} />} onPress={async () => { const url = 'https://www.apple.com/legal/internet-services/itunes/dev/stdeula/'; const supported = await Linking.canOpenURL(url); diff --git a/apps/ui/sources/components/settings/actions/ActionsSettingsView.tsx b/apps/ui/sources/components/settings/actions/ActionsSettingsView.tsx index af36d8315..7b4ee3080 100644 --- a/apps/ui/sources/components/settings/actions/ActionsSettingsView.tsx +++ b/apps/ui/sources/components/settings/actions/ActionsSettingsView.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { ActionsSettingsV1Schema, @@ -81,6 +82,7 @@ const PLACEMENT_ICONS: Record<ActionUiPlacement, React.ComponentProps<typeof Ion }; export const ActionsSettingsView = React.memo(function ActionsSettingsView() { + const { theme } = useUnistyles(); const [raw, setRaw] = useSettingMutable('actionsSettingsV1'); const settings = React.useMemo(() => { @@ -108,7 +110,7 @@ export const ActionsSettingsView = React.memo(function ActionsSettingsView() { <Item title="About" subtitle="Enable or disable actions globally, per surface (UI/voice/MCP), and per placement (where they appear in the UI). Disabled actions are fail-closed at runtime." - icon={<Ionicons name="information-circle-outline" size={29} color="#8E8E93" />} + icon={<Ionicons name="information-circle-outline" size={29} color={theme.colors.textSecondary} />} showChevron={false} /> </ItemGroup> @@ -189,7 +191,7 @@ export const ActionsSettingsView = React.memo(function ActionsSettingsView() { <Ionicons name={effectiveEnabled ? 'flash-outline' : 'flash-off-outline'} size={29} - color={effectiveEnabled ? '#34C759' : '#FF3B30'} + color={effectiveEnabled ? theme.colors.success : theme.colors.warningCritical} /> } rightElement={<Switch value={globallyEnabled} onValueChange={toggleGlobal} />} @@ -204,7 +206,7 @@ export const ActionsSettingsView = React.memo(function ActionsSettingsView() { key={`surface:${surface}`} title={SURFACE_LABELS[surface] ?? String(surface)} subtitle={isEnabledOnSurface ? 'Enabled' : 'Disabled'} - icon={<Ionicons name={SURFACE_ICONS[surface] ?? 'options-outline'} size={29} color="#8E8E93" />} + icon={<Ionicons name={SURFACE_ICONS[surface] ?? 'options-outline'} size={29} color={theme.colors.textSecondary} />} rightElement={<Switch value={isEnabledOnSurface} onValueChange={() => toggleSurface(surface)} />} showChevron={false} onPress={() => toggleSurface(surface)} @@ -219,7 +221,7 @@ export const ActionsSettingsView = React.memo(function ActionsSettingsView() { key={`placement:${placement}`} title={PLACEMENT_LABELS[placement] ?? String(placement)} subtitle={isEnabledInPlacement ? 'Enabled' : 'Disabled'} - icon={<Ionicons name={PLACEMENT_ICONS[placement] ?? 'options-outline'} size={29} color="#8E8E93" />} + icon={<Ionicons name={PLACEMENT_ICONS[placement] ?? 'options-outline'} size={29} color={theme.colors.textSecondary} />} rightElement={<Switch value={isEnabledInPlacement} onValueChange={() => togglePlacement(placement)} />} showChevron={false} onPress={() => togglePlacement(placement)} diff --git a/apps/ui/sources/components/settings/attachments/AttachmentsSettingsView.tsx b/apps/ui/sources/components/settings/attachments/AttachmentsSettingsView.tsx index aeebb4d55..35dead722 100644 --- a/apps/ui/sources/components/settings/attachments/AttachmentsSettingsView.tsx +++ b/apps/ui/sources/components/settings/attachments/AttachmentsSettingsView.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; @@ -74,6 +75,7 @@ function parsePositiveInt(input: string, opts: Readonly<{ min: number; max: numb } export const AttachmentsSettingsView = React.memo(function AttachmentsSettingsView() { + const { theme } = useUnistyles(); const attachmentsEnabled = useFeatureEnabled('attachments.uploads'); const [uploadLocation, setUploadLocation] = useSettingMutable('attachmentsUploadsUploadLocation'); @@ -101,7 +103,7 @@ export const AttachmentsSettingsView = React.memo(function AttachmentsSettingsVi <Item title="File uploads" subtitle="Disabled" - icon={<Ionicons name="attach-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="attach-outline" size={29} color={theme.colors.warningCritical} />} showChevron={false} /> </ItemGroup> @@ -110,7 +112,7 @@ export const AttachmentsSettingsView = React.memo(function AttachmentsSettingsVi } const renderIcon = (iconName: IoniconName) => ( - <Ionicons name={iconName} size={29} color="#8E8E93" /> + <Ionicons name={iconName} size={29} color={theme.colors.textSecondary} /> ); return ( @@ -125,7 +127,7 @@ export const AttachmentsSettingsView = React.memo(function AttachmentsSettingsVi title={option.title} subtitle={option.subtitle} icon={renderIcon(option.iconName)} - rightElement={effectiveUploadLocation === option.id ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={effectiveUploadLocation === option.id ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setUploadLocation(option.id)} showChevron={false} /> @@ -164,7 +166,7 @@ export const AttachmentsSettingsView = React.memo(function AttachmentsSettingsVi title={option.title} subtitle={option.subtitle} icon={renderIcon(option.iconName)} - rightElement={effectiveIgnoreStrategy === option.id ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={effectiveIgnoreStrategy === option.id ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setVcsIgnoreStrategy(option.id)} showChevron={false} /> @@ -240,4 +242,3 @@ export const AttachmentsSettingsView = React.memo(function AttachmentsSettingsVi </ItemList> ); }); - diff --git a/apps/ui/sources/components/settings/bugReports/BugReportChoiceRow.tsx b/apps/ui/sources/components/settings/bugReports/BugReportChoiceRow.tsx index 180148449..6abb31dc2 100644 --- a/apps/ui/sources/components/settings/bugReports/BugReportChoiceRow.tsx +++ b/apps/ui/sources/components/settings/bugReports/BugReportChoiceRow.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Pressable, View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { bugReportComposerStyles } from './bugReportComposerStyles'; diff --git a/apps/ui/sources/components/settings/bugReports/BugReportComposerSections.tsx b/apps/ui/sources/components/settings/bugReports/BugReportComposerSections.tsx index 07e87bf46..86288a64d 100644 --- a/apps/ui/sources/components/settings/bugReports/BugReportComposerSections.tsx +++ b/apps/ui/sources/components/settings/bugReports/BugReportComposerSections.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { Pressable, TextInput, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Switch } from '@/components/ui/forms/Switch'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { type BugReportDeploymentType, type BugReportFrequency, type BugReportSeverity } from './bugReportFallback'; import { BugReportChoiceRow } from './BugReportChoiceRow'; diff --git a/apps/ui/sources/components/settings/bugReports/BugReportComposerView.tsx b/apps/ui/sources/components/settings/bugReports/BugReportComposerView.tsx index a0e4c1921..b203fc857 100644 --- a/apps/ui/sources/components/settings/bugReports/BugReportComposerView.tsx +++ b/apps/ui/sources/components/settings/bugReports/BugReportComposerView.tsx @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useUnistyles } from 'react-native-unistyles'; import { layout } from '@/components/ui/layout/layout'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { useFeatureDetails } from '@/hooks/server/useFeatureDetails'; import { useFeatureEnabled } from '@/hooks/server/useFeatureEnabled'; import { getActiveServerSnapshot } from '@/sync/domains/server/serverRuntime'; diff --git a/apps/ui/sources/components/settings/bugReports/BugReportDiagnosticsPreviewModal.test.tsx b/apps/ui/sources/components/settings/bugReports/BugReportDiagnosticsPreviewModal.test.tsx index 8c5949f3b..8c18da50c 100644 --- a/apps/ui/sources/components/settings/bugReports/BugReportDiagnosticsPreviewModal.test.tsx +++ b/apps/ui/sources/components/settings/bugReports/BugReportDiagnosticsPreviewModal.test.tsx @@ -19,6 +19,14 @@ vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 20, bottom: 20, left: 0, right: 0 }), })); +function findStyleValue(style: any, key: string) { + const list = Array.isArray(style) ? style : [style]; + for (const entry of list) { + if (entry && typeof entry === 'object' && key in entry) return (entry as any)[key]; + } + return undefined; +} + vi.mock('react-native-unistyles', () => ({ StyleSheet: { create: (styles: any) => styles }, useUnistyles: () => ({ @@ -40,12 +48,38 @@ vi.mock('@expo/vector-icons', () => { return { Ionicons: (props: any) => React.createElement('Ionicons', props) }; }); -vi.mock('@/components/ui/text/StyledText', () => { +vi.mock('@/components/ui/text/Text', () => { const React = require('react'); return { Text: (props: any) => React.createElement('Text', props, props.children) }; }); describe('BugReportDiagnosticsPreviewModal', () => { + it('sets an explicit height so the scroll body can measure on native', async () => { + const { BugReportDiagnosticsPreviewModal } = await import('./BugReportDiagnosticsPreviewModal'); + + const onClose = vi.fn(); + const artifacts = [ + { + filename: 'app-context.json', + sourceKind: 'ui-mobile', + contentType: 'application/json', + sizeBytes: 10, + content: '{"hello":"world"}', + }, + ]; + + let tree: renderer.ReactTestRenderer | null = null; + act(() => { + tree = renderer.create(<BugReportDiagnosticsPreviewModal artifacts={artifacts as any} onClose={onClose} />); + }); + + // window.height=700, insets top+bottom=40, extra padding=96 => 564 + const expected = 564; + const rootView = tree!.root.findByType('View' as any); + expect(findStyleValue(rootView.props.style, 'height')).toBe(expected); + expect(findStyleValue(rootView.props.style, 'maxHeight')).toBe(expected); + }); + it('drills into an artifact and shows its content', async () => { const { BugReportDiagnosticsPreviewModal } = await import('./BugReportDiagnosticsPreviewModal'); diff --git a/apps/ui/sources/components/settings/bugReports/BugReportDiagnosticsPreviewModal.tsx b/apps/ui/sources/components/settings/bugReports/BugReportDiagnosticsPreviewModal.tsx index f9a614463..7f0fd6025 100644 --- a/apps/ui/sources/components/settings/bugReports/BugReportDiagnosticsPreviewModal.tsx +++ b/apps/ui/sources/components/settings/bugReports/BugReportDiagnosticsPreviewModal.tsx @@ -4,7 +4,7 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; export type BugReportDiagnosticsPreviewArtifact = { filename: string; @@ -116,8 +116,13 @@ export function BugReportDiagnosticsPreviewModal(props: Readonly<{ const maxHeight = Math.max(240, Math.floor(window.height - (insets.top + insets.bottom + 96))); const [selected, setSelected] = React.useState<BugReportDiagnosticsPreviewArtifact | null>(null); + // IMPORTANT (native Android): + // When the card only has a `maxHeight`, React Native can lay it out with an + // unconstrained height. In that case, the ScrollView with `flex: 1` may + // collapse to ~0px, leaving only the header visible. + // Give the card a concrete height so the ScrollView can measure reliably. return ( - <View style={[s.card, { maxHeight }]}> + <View style={[s.card, { height: maxHeight, maxHeight }]}> <View style={s.header}> <View style={s.headerLeft}> {selected ? ( diff --git a/apps/ui/sources/components/settings/bugReports/BugReportSimilarIssuesSection.tsx b/apps/ui/sources/components/settings/bugReports/BugReportSimilarIssuesSection.tsx index eddad0042..ce8fa1971 100644 --- a/apps/ui/sources/components/settings/bugReports/BugReportSimilarIssuesSection.tsx +++ b/apps/ui/sources/components/settings/bugReports/BugReportSimilarIssuesSection.tsx @@ -3,7 +3,7 @@ import { ActivityIndicator, Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import type { BugReportSimilarIssue } from './bugReportServiceClient'; import { bugReportComposerStyles } from './bugReportComposerStyles'; diff --git a/apps/ui/sources/components/settings/bugReports/bugReportFallback.test.ts b/apps/ui/sources/components/settings/bugReports/bugReportFallback.test.ts index fbaca881e..1b4e9a0be 100644 --- a/apps/ui/sources/components/settings/bugReports/bugReportFallback.test.ts +++ b/apps/ui/sources/components/settings/bugReports/bugReportFallback.test.ts @@ -33,8 +33,8 @@ describe('formatFallbackIssueBody', () => { diagnosticsIncluded: false, }); - expect(body).toContain('## Summary'); - expect(body).toContain('## Reproduction Steps'); + expect(body).toMatch(/(^|\n)#+\s+Summary\b/); + expect(body).toMatch(/(^|\n)#+\s+Reproduction steps\b/i); expect(body).toContain('Sessions drop after a minute.'); expect(body).toContain('Diagnostics: not included'); expect(body).toContain('Upgraded CLI yesterday'); diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.profileId.test.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.profileId.test.tsx index 5cd49717d..e62f0f62d 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.profileId.test.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.profileId.test.tsx @@ -30,22 +30,26 @@ vi.mock('@/hooks/server/useFeatureEnabled', () => ({ useFeatureEnabled: (featureId: string) => (featureId === 'connectedServices.quotas' ? false : true), })); -vi.mock('@/sync/store/hooks', () => ({ - useProfile: () => ({ - connectedServicesV2: [ - { - serviceId: 'openai-codex', - profiles: [{ profileId: 'work', status: 'connected', providerEmail: null }], - }, - ], - }), - useSettings: () => ({ - connectedServicesDefaultProfileByServiceId: { 'openai-codex': 'work' }, - connectedServicesProfileLabelByKey: {}, - connectedServicesQuotaPinnedMeterIdsByKey: {}, - connectedServicesQuotaSummaryStrategyByKey: {}, - }), -})); +vi.mock('@/sync/store/hooks', async () => { + const actual = await vi.importActual<typeof import('@/sync/store/hooks')>('@/sync/store/hooks'); + return { + ...actual, + useProfile: () => ({ + connectedServicesV2: [ + { + serviceId: 'openai-codex', + profiles: [{ profileId: 'work', status: 'connected', providerEmail: null }], + }, + ], + }), + useSettings: () => ({ + connectedServicesDefaultProfileByServiceId: { 'openai-codex': 'work' }, + connectedServicesProfileLabelByKey: {}, + connectedServicesQuotaPinnedMeterIdsByKey: {}, + connectedServicesQuotaSummaryStrategyByKey: {}, + }), + }; +}); vi.mock('@/sync/sync', () => ({ sync: { refreshProfile: vi.fn(async () => {}), applySettings: vi.fn(async () => {}) }, diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.quotas.test.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.quotas.test.tsx index a2680f046..3a0b140f1 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.quotas.test.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.quotas.test.tsx @@ -25,22 +25,26 @@ vi.mock('@/hooks/server/useFeatureEnabled', () => ({ useFeatureEnabled: (featureId: string) => useFeatureEnabledSpy(featureId), })); -vi.mock('@/sync/store/hooks', () => ({ - useProfile: () => ({ - connectedServicesV2: [ - { - serviceId: 'openai-codex', - profiles: [{ profileId: 'work', status: 'connected', providerEmail: null }], - }, - ], - }), - useSettings: () => ({ - connectedServicesDefaultProfileByServiceId: { 'openai-codex': 'work' }, - connectedServicesProfileLabelByKey: {}, - connectedServicesQuotaPinnedMeterIdsByKey: {}, - connectedServicesQuotaSummaryStrategyByKey: {}, - }), -})); +vi.mock('@/sync/store/hooks', async () => { + const actual = await vi.importActual<typeof import('@/sync/store/hooks')>('@/sync/store/hooks'); + return { + ...actual, + useProfile: () => ({ + connectedServicesV2: [ + { + serviceId: 'openai-codex', + profiles: [{ profileId: 'work', status: 'connected', providerEmail: null }], + }, + ], + }), + useSettings: () => ({ + connectedServicesDefaultProfileByServiceId: { 'openai-codex': 'work' }, + connectedServicesProfileLabelByKey: {}, + connectedServicesQuotaPinnedMeterIdsByKey: {}, + connectedServicesQuotaSummaryStrategyByKey: {}, + }), + }; +}); const applySettingsSpy = vi.fn(async (_update: unknown) => {}); vi.mock('@/sync/sync', () => ({ diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.tsx index 1e9992554..0db26de2c 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceDetailView.tsx @@ -2,11 +2,12 @@ import * as React from 'react'; import { View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { ItemList } from '@/components/ui/lists/ItemList'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Modal } from '@/modal'; import { t } from '@/text'; import { useAuth } from '@/auth/context/AuthContext'; @@ -36,6 +37,7 @@ function asStringParam(value: unknown): string { } export const ConnectedServiceDetailView = React.memo(function ConnectedServiceDetailView() { + const { theme } = useUnistyles(); const router = useRouter(); const params = useLocalSearchParams(); const auth = useAuth(); @@ -69,7 +71,7 @@ export const ConnectedServiceDetailView = React.memo(function ConnectedServiceDe <ItemGroup> <Item title={t('common.close') ?? 'Done'} - icon={<Ionicons name="close-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="close-outline" size={22} color={theme.colors.accent.blue} />} onPress={() => router.back()} showChevron={false} /> @@ -257,7 +259,7 @@ export const ConnectedServiceDetailView = React.memo(function ConnectedServiceDe <ItemGroup> <Item title={t('common.close') ?? 'Done'} - icon={<Ionicons name="close-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="close-outline" size={22} color={theme.colors.accent.blue} />} onPress={() => router.back()} showChevron={false} /> @@ -305,7 +307,7 @@ export const ConnectedServiceDetailView = React.memo(function ConnectedServiceDe <ItemGroup> <Item title={t('common.close') ?? 'Done'} - icon={<Ionicons name="close-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="close-outline" size={22} color={theme.colors.accent.blue} />} onPress={() => router.back()} showChevron={false} /> diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceOauthPasteView.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceOauthPasteView.tsx index 547843995..618e14f41 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceOauthPasteView.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceOauthPasteView.tsx @@ -5,7 +5,7 @@ import tweetnacl from 'tweetnacl'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { ItemList } from '@/components/ui/lists/ItemList'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Modal } from '@/modal'; import { useAuth } from '@/auth/context/AuthContext'; import { sync } from '@/sync/sync'; diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaBadgesView.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaBadgesView.tsx index 63695302c..73a57efe7 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaBadgesView.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaBadgesView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; const stylesheet = StyleSheet.create((theme) => ({ container: { diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaCard.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaCard.tsx index c1f2f8e6f..b045436f8 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaCard.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaCard.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import { View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { useAuth } from '@/auth/context/AuthContext'; import { getConnectedServiceQuotaSnapshotSealed, requestConnectedServiceQuotaSnapshotRefresh } from '@/sync/api/account/apiConnectedServicesQuotasV2'; import { openConnectedServiceQuotaSnapshot } from '@/sync/domains/connectedServices/openConnectedServiceQuotaSnapshot'; @@ -33,6 +34,7 @@ export const ConnectedServiceQuotaCard = React.memo(function ConnectedServiceQuo onSetPinnedMeterIds: (next: ReadonlyArray<string>) => void; onSnapshot?: (snapshot: ConnectedServiceQuotaSnapshotV1 | null) => void; }>) { + const { theme } = useUnistyles(); const auth = useAuth(); const credentials = auth.credentials; @@ -119,7 +121,7 @@ export const ConnectedServiceQuotaCard = React.memo(function ConnectedServiceQuo <Item title="Refresh" subtitle={loading ? 'Loading…' : error ? `Error: ${error}` : snapshot ? `Last updated: ${formatTimestamp(snapshot.fetchedAt)}${isStale ? ' • stale' : ''}` : 'No quota data yet'} - icon={<Ionicons name="refresh-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="refresh-outline" size={22} color={theme.colors.accent.blue} />} onPress={() => void requestRefreshAndReload()} showChevron={false} /> diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaMeterRow.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaMeterRow.tsx index 564498e8f..ec04d8996 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaMeterRow.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServiceQuotaMeterRow.tsx @@ -4,7 +4,7 @@ import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import type { ConnectedServiceQuotaMeterV1 } from '@happier-dev/protocol'; import { clampQuotaPct, deriveQuotaUtilizationPct } from '@/sync/domains/connectedServices/deriveQuotaUtilizationPct'; diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.quotas.test.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.quotas.test.tsx index 2e9e35f59..7c2ceee59 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.quotas.test.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.quotas.test.tsx @@ -41,6 +41,7 @@ vi.mock('@/sync/store/hooks', () => ({ ], }), useSettings: () => useSettingsSpy(), + useLocalSetting: () => 1, })); const { getConnectedServiceQuotaSnapshotSealedSpy } = vi.hoisted(() => ({ diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.test.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.test.tsx index 3e16818f0..cfc6092e9 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.test.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.test.tsx @@ -41,6 +41,7 @@ vi.mock('@/sync/store/hooks', () => ({ connectedServicesDefaultProfileByServiceId: {}, connectedServicesProfileLabelByKey: {}, }), + useLocalSetting: () => 1, })); vi.mock('@/components/ui/lists/ItemList', () => ({ @@ -55,8 +56,9 @@ vi.mock('@/components/ui/lists/Item', () => ({ Item: (props: any) => React.createElement('Item', props), })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/modal', () => ({ diff --git a/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.tsx b/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.tsx index efb145215..ef85d997e 100644 --- a/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.tsx +++ b/apps/ui/sources/components/settings/connectedServices/ConnectedServicesSettingsView.tsx @@ -2,11 +2,12 @@ import * as React from 'react'; import { View } from 'react-native'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { ItemList } from '@/components/ui/lists/ItemList'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { t } from '@/text'; import { useProfile } from '@/sync/store/hooks'; import { useSettings } from '@/sync/store/hooks'; @@ -19,6 +20,7 @@ import { useConnectedServiceQuotaBadges } from '@/hooks/server/connectedServices import { connectedServiceProfileKey, resolveConnectedServiceDefaultProfileId } from '@/sync/domains/connectedServices/connectedServiceProfilePreferences'; export const ConnectedServicesSettingsView = React.memo(function ConnectedServicesSettingsView() { + const { theme } = useUnistyles(); const profile = useProfile(); const settings = useSettings(); const router = useRouter(); @@ -65,7 +67,7 @@ export const ConnectedServicesSettingsView = React.memo(function ConnectedServic <ItemGroup> <Item title={t('common.close') ?? 'Done'} - icon={<Ionicons name="close-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="close-outline" size={22} color={theme.colors.accent.blue} />} onPress={() => router.back()} showChevron={false} /> @@ -111,7 +113,7 @@ export const ConnectedServicesSettingsView = React.memo(function ConnectedServic key={serviceId} title={label} subtitle={subtitle} - icon={<Ionicons name="key-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="key-outline" size={22} color={theme.colors.accent.blue} />} rightElement={badges.length > 0 ? <ConnectedServiceQuotaBadgesView badges={badges} /> : undefined} onPress={async () => { try { @@ -133,7 +135,7 @@ export const ConnectedServicesSettingsView = React.memo(function ConnectedServic <ItemGroup> <Item title={t('common.close') ?? 'Done'} - icon={<Ionicons name="close-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="close-outline" size={22} color={theme.colors.accent.blue} />} onPress={() => router.back()} showChevron={false} /> diff --git a/apps/ui/sources/components/settings/connectedServices/detail/ConnectedServiceDetailActionsGroup.tsx b/apps/ui/sources/components/settings/connectedServices/detail/ConnectedServiceDetailActionsGroup.tsx index 5951546bb..c50ec41eb 100644 --- a/apps/ui/sources/components/settings/connectedServices/detail/ConnectedServiceDetailActionsGroup.tsx +++ b/apps/ui/sources/components/settings/connectedServices/detail/ConnectedServiceDetailActionsGroup.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; @@ -12,35 +13,36 @@ export const ConnectedServiceDetailActionsGroup = React.memo(function ConnectedS onAddOauthProfile: () => void; onConnectSetupToken: () => void; }>) { + const { theme } = useUnistyles(); + return ( <ItemGroup title="Actions"> <Item title="Set default profile" subtitle={props.defaultProfileId ? `Default: ${props.defaultProfileId}` : 'Choose which profile is selected by default'} - icon={<Ionicons name="star-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="star-outline" size={22} color={theme.colors.accent.blue} />} onPress={props.onSetDefaultProfile} /> <Item title="Set profile label" subtitle="Optional label shown in auth pickers" - icon={<Ionicons name="pencil-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="pencil-outline" size={22} color={theme.colors.accent.blue} />} onPress={props.onSetProfileLabel} /> <Item title="Add OAuth profile" subtitle="Connect a new account profile" - icon={<Ionicons name="add-circle-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="add-circle-outline" size={22} color={theme.colors.accent.blue} />} onPress={props.onAddOauthProfile} /> {props.supportsSetupToken ? ( <Item title="Connect via setup-token" subtitle="Paste a Claude setup-token" - icon={<Ionicons name="key-outline" size={22} color="#007AFF" />} + icon={<Ionicons name="key-outline" size={22} color={theme.colors.accent.blue} />} onPress={props.onConnectSetupToken} /> ) : null} </ItemGroup> ); }); - diff --git a/apps/ui/sources/components/settings/connectedServices/detail/ConnectedServiceDetailProfilesGroup.tsx b/apps/ui/sources/components/settings/connectedServices/detail/ConnectedServiceDetailProfilesGroup.tsx index 099147c84..1e8435f46 100644 --- a/apps/ui/sources/components/settings/connectedServices/detail/ConnectedServiceDetailProfilesGroup.tsx +++ b/apps/ui/sources/components/settings/connectedServices/detail/ConnectedServiceDetailProfilesGroup.tsx @@ -4,7 +4,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { resolveConnectedServiceProfileLabel, connectedServiceProfileKey } from '@/sync/domains/connectedServices/connectedServiceProfilePreferences'; import { computeConnectedServiceQuotaSummaryBadges } from '@/sync/domains/connectedServices/connectedServiceQuotaBadges'; import type { ConnectedServiceId, ConnectedServiceQuotaSnapshotV1 } from '@happier-dev/protocol'; diff --git a/apps/ui/sources/components/settings/memory/MemorySettingsBudgetsSection.tsx b/apps/ui/sources/components/settings/memory/MemorySettingsBudgetsSection.tsx index 22581c625..e4ae068cd 100644 --- a/apps/ui/sources/components/settings/memory/MemorySettingsBudgetsSection.tsx +++ b/apps/ui/sources/components/settings/memory/MemorySettingsBudgetsSection.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { Item } from '@/components/ui/lists/Item'; @@ -11,6 +12,7 @@ export const MemorySettingsBudgetsSection = React.memo(function MemorySettingsBu settings: MemorySettingsV1; writeSettings: (next: MemorySettingsV1) => void | Promise<void>; }>) { + const { theme } = useUnistyles(); const { settings } = props; return ( @@ -22,7 +24,7 @@ export const MemorySettingsBudgetsSection = React.memo(function MemorySettingsBu testID="memory-settings-budget-light" title="Light index budget" subtitle={`${settings.budgets.maxDiskMbLight} MB`} - icon={<Ionicons name="server-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="server-outline" size={29} color={theme.colors.accent.blue} />} onPress={async () => { const next = await Modal.prompt( 'Light index budget', @@ -47,7 +49,7 @@ export const MemorySettingsBudgetsSection = React.memo(function MemorySettingsBu testID="memory-settings-budget-deep" title="Deep index budget" subtitle={`${settings.budgets.maxDiskMbDeep} MB`} - icon={<Ionicons name="server-outline" size={29} color="#AF52DE" />} + icon={<Ionicons name="server-outline" size={29} color={theme.colors.accent.purple} />} onPress={async () => { const next = await Modal.prompt( 'Deep index budget', @@ -71,4 +73,3 @@ export const MemorySettingsBudgetsSection = React.memo(function MemorySettingsBu </ItemGroup> ); }); - diff --git a/apps/ui/sources/components/settings/memory/MemorySettingsEmbeddingsSection.tsx b/apps/ui/sources/components/settings/memory/MemorySettingsEmbeddingsSection.tsx index ed0b7ab02..020756028 100644 --- a/apps/ui/sources/components/settings/memory/MemorySettingsEmbeddingsSection.tsx +++ b/apps/ui/sources/components/settings/memory/MemorySettingsEmbeddingsSection.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { Item } from '@/components/ui/lists/Item'; @@ -12,6 +13,7 @@ export const MemorySettingsEmbeddingsSection = React.memo(function MemorySetting settings: MemorySettingsV1; writeSettings: (next: MemorySettingsV1) => void | Promise<void>; }>) { + const { theme } = useUnistyles(); const { settings } = props; if (settings.indexMode !== 'deep') return null; @@ -25,7 +27,7 @@ export const MemorySettingsEmbeddingsSection = React.memo(function MemorySetting testID="memory-settings-embeddings-enabled-item" title="Enable embeddings" subtitle="Improves ranking for deep search (downloads a model on first use)" - icon={<Ionicons name="sparkles-outline" size={29} color="#34C759" />} + icon={<Ionicons name="sparkles-outline" size={29} color={theme.colors.success} />} rightElement={( <Switch testID="memory-settings-embeddings-enabled" @@ -43,7 +45,7 @@ export const MemorySettingsEmbeddingsSection = React.memo(function MemorySetting <Item title="Embeddings model" subtitle={settings.embeddings.modelId} - icon={<Ionicons name="cube-outline" size={29} color="#AF52DE" />} + icon={<Ionicons name="cube-outline" size={29} color={theme.colors.accent.purple} />} onPress={async () => { const next = await Modal.prompt( 'Embeddings model', @@ -67,4 +69,3 @@ export const MemorySettingsEmbeddingsSection = React.memo(function MemorySetting </ItemGroup> ); }); - diff --git a/apps/ui/sources/components/settings/memory/MemorySettingsPrivacySection.tsx b/apps/ui/sources/components/settings/memory/MemorySettingsPrivacySection.tsx index 234e197d3..8d321aedd 100644 --- a/apps/ui/sources/components/settings/memory/MemorySettingsPrivacySection.tsx +++ b/apps/ui/sources/components/settings/memory/MemorySettingsPrivacySection.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { Item } from '@/components/ui/lists/Item'; @@ -11,6 +12,7 @@ export const MemorySettingsPrivacySection = React.memo(function MemorySettingsPr settings: MemorySettingsV1; writeSettings: (next: MemorySettingsV1) => void | Promise<void>; }>) { + const { theme } = useUnistyles(); const { settings } = props; return ( @@ -22,7 +24,7 @@ export const MemorySettingsPrivacySection = React.memo(function MemorySettingsPr testID="memory-settings-delete-on-disable-item" title="Delete on disable" subtitle="Remove local indexes and caches when memory search is turned off" - icon={<Ionicons name="trash-outline" size={29} color="#FF3B30" />} + icon={<Ionicons name="trash-outline" size={29} color={theme.colors.warningCritical} />} rightElement={( <Switch testID="memory-settings-delete-on-disable" @@ -37,4 +39,3 @@ export const MemorySettingsPrivacySection = React.memo(function MemorySettingsPr </ItemGroup> ); }); - diff --git a/apps/ui/sources/components/settings/memory/MemorySettingsView.rpc.test.tsx b/apps/ui/sources/components/settings/memory/MemorySettingsView.rpc.test.tsx index 8823000ee..82d0378c1 100644 --- a/apps/ui/sources/components/settings/memory/MemorySettingsView.rpc.test.tsx +++ b/apps/ui/sources/components/settings/memory/MemorySettingsView.rpc.test.tsx @@ -16,12 +16,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { colors: { textSecondary: '#999', text: '#111' } }, - }), -})); - vi.mock('@/components/ui/lists/ItemList', () => ({ ItemList: (props: any) => React.createElement('ItemList', props, props.children), })); @@ -42,8 +36,9 @@ vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ DropdownMenu: (props: any) => React.createElement('DropdownMenu', props), })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: (props: any) => React.createElement('Text', props, props.children), + TextInput: 'TextInput', })); vi.mock('@/text', () => ({ diff --git a/apps/ui/sources/components/settings/memory/MemorySettingsView.tsx b/apps/ui/sources/components/settings/memory/MemorySettingsView.tsx index ed30fafe3..fadf9abe1 100644 --- a/apps/ui/sources/components/settings/memory/MemorySettingsView.tsx +++ b/apps/ui/sources/components/settings/memory/MemorySettingsView.tsx @@ -8,7 +8,7 @@ import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { Item } from '@/components/ui/lists/Item'; import { Switch } from '@/components/ui/forms/Switch'; import { DropdownMenu } from '@/components/ui/forms/dropdown/DropdownMenu'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Modal } from '@/modal'; import { getActiveServerSnapshot } from '@/sync/domains/server/serverRuntime'; @@ -128,7 +128,7 @@ export const MemorySettingsView = React.memo(function MemorySettingsView() { <Item title="Memory search is disabled" subtitle="Open Settings → Features to enable memory.search" - icon={<Ionicons name="search-outline" size={29} color="#34C759" />} + icon={<Ionicons name="search-outline" size={29} color={theme.colors.success} />} onPress={() => { void Modal.alert('Memory search disabled', 'Enable memory.search in Settings → Features.'); }} /> </ItemGroup> @@ -145,7 +145,7 @@ export const MemorySettingsView = React.memo(function MemorySettingsView() { <Item title="Machine" subtitle={selectedMachineTitle} - icon={<Ionicons name="desktop-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="desktop-outline" size={29} color={theme.colors.accent.blue} />} rightElement={loading ? <Text>Loading…</Text> : null} showChevron={false} /> @@ -162,14 +162,14 @@ export const MemorySettingsView = React.memo(function MemorySettingsView() { }} itemTrigger={{ title: 'Change machine', - icon: <Ionicons name="swap-horizontal-outline" size={29} color="#5856D6" />, + icon: <Ionicons name="swap-horizontal-outline" size={29} color={theme.colors.accent.indigo} />, }} /> </View> <Item title="Enabled" subtitle="Build and maintain a local index on this machine" - icon={<Ionicons name="search-outline" size={29} color="#34C759" />} + icon={<Ionicons name="search-outline" size={29} color={theme.colors.success} />} rightElement={( <Switch value={settings.enabled} @@ -198,7 +198,7 @@ export const MemorySettingsView = React.memo(function MemorySettingsView() { }} itemTrigger={{ title: 'Mode', - icon: <Ionicons name="options-outline" size={29} color="#FF9500" />, + icon: <Ionicons name="options-outline" size={29} color={theme.colors.accent.orange} />, }} /> </ItemGroup> @@ -224,7 +224,7 @@ export const MemorySettingsView = React.memo(function MemorySettingsView() { }} itemTrigger={{ title: 'Policy', - icon: <Ionicons name="time-outline" size={29} color="#AF52DE" />, + icon: <Ionicons name="time-outline" size={29} color={theme.colors.accent.purple} />, }} /> </ItemGroup> @@ -239,7 +239,7 @@ export const MemorySettingsView = React.memo(function MemorySettingsView() { testID="memory-settings-summarizer-backend" title="Summarizer backend" subtitle={settings.hints.summarizerBackendId} - icon={<Ionicons name="server-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="server-outline" size={29} color={theme.colors.accent.blue} />} onPress={async () => { const next = await Modal.prompt( 'Summarizer backend', @@ -264,7 +264,7 @@ export const MemorySettingsView = React.memo(function MemorySettingsView() { testID="memory-settings-summarizer-model" title="Summarizer model" subtitle={settings.hints.summarizerModelId} - icon={<Ionicons name="cube-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="cube-outline" size={29} color={theme.colors.accent.indigo} />} onPress={async () => { const next = await Modal.prompt( 'Summarizer model', @@ -300,7 +300,7 @@ export const MemorySettingsView = React.memo(function MemorySettingsView() { }} itemTrigger={{ title: 'Summarizer permissions', - icon: <Ionicons name="lock-closed-outline" size={29} color="#FF3B30" />, + icon: <Ionicons name="lock-closed-outline" size={29} color={theme.colors.warningCritical} />, }} /> </ItemGroup> diff --git a/apps/ui/sources/components/settings/notifications/NotificationsSettingsView.tsx b/apps/ui/sources/components/settings/notifications/NotificationsSettingsView.tsx index 8fae0acfb..bf36d0963 100644 --- a/apps/ui/sources/components/settings/notifications/NotificationsSettingsView.tsx +++ b/apps/ui/sources/components/settings/notifications/NotificationsSettingsView.tsx @@ -11,7 +11,9 @@ import { Switch } from '@/components/ui/forms/Switch'; import { sync } from '@/sync/sync'; import { useSettings } from '@/sync/domains/state/storage'; -import { DEFAULT_NOTIFICATIONS_SETTINGS_V1, NotificationsSettingsV1Schema } from '@happier-dev/protocol'; +import { t } from '@/text'; + +import { DEFAULT_NOTIFICATIONS_SETTINGS_V1, NotificationsSettingsV1Schema, type ForegroundBehavior } from '@happier-dev/protocol'; export const NotificationsSettingsView = React.memo(function NotificationsSettingsView() { const { theme } = useUnistyles(); @@ -27,6 +29,7 @@ export const NotificationsSettingsView = React.memo(function NotificationsSettin }, [notificationsRaw]); const pushEnabled = notifications.pushEnabled !== false; + const foregroundBehavior: ForegroundBehavior = notifications.foregroundBehavior ?? 'full'; const setNotifications = React.useCallback((next: Partial<typeof notifications>) => { sync.applySettings({ @@ -47,7 +50,7 @@ export const NotificationsSettingsView = React.memo(function NotificationsSettin <Item title="Enabled" subtitle="Allow push notifications on this account" - icon={<Ionicons name="notifications-outline" size={29} color="#007AFF" />} + icon={<Ionicons name="notifications-outline" size={29} color={theme.colors.accent.blue} />} rightElement={( <Switch value={pushEnabled} @@ -65,7 +68,7 @@ export const NotificationsSettingsView = React.memo(function NotificationsSettin <Item title="Ready" subtitle="Notify when a turn finishes and the agent is waiting for your command" - icon={<Ionicons name="checkmark-circle-outline" size={29} color="#34C759" />} + icon={<Ionicons name="checkmark-circle-outline" size={29} color={theme.colors.success} />} rightElement={( <Switch value={notifications.ready !== false} @@ -89,7 +92,39 @@ export const NotificationsSettingsView = React.memo(function NotificationsSettin showChevron={false} /> </ItemGroup> + + <ItemGroup + title={t('settingsNotifications.foregroundBehavior.title')} + footer={t('settingsNotifications.foregroundBehavior.footer')} + > + <Item + title={t('settingsNotifications.foregroundBehavior.full')} + subtitle={t('settingsNotifications.foregroundBehavior.fullDescription')} + icon={<Ionicons name="volume-high-outline" size={29} color={theme.colors.accent.blue} />} + selected={foregroundBehavior === 'full'} + disabled={!pushEnabled} + onPress={() => setNotifications({ foregroundBehavior: 'full' })} + showChevron={false} + /> + <Item + title={t('settingsNotifications.foregroundBehavior.silent')} + subtitle={t('settingsNotifications.foregroundBehavior.silentDescription')} + icon={<Ionicons name="volume-off-outline" size={29} color={theme.colors.accent.blue} />} + selected={foregroundBehavior === 'silent'} + disabled={!pushEnabled} + onPress={() => setNotifications({ foregroundBehavior: 'silent' })} + showChevron={false} + /> + <Item + title={t('settingsNotifications.foregroundBehavior.off')} + subtitle={t('settingsNotifications.foregroundBehavior.offDescription')} + icon={<Ionicons name="notifications-off-outline" size={29} color={theme.colors.accent.blue} />} + selected={foregroundBehavior === 'off'} + disabled={!pushEnabled} + onPress={() => setNotifications({ foregroundBehavior: 'off' })} + showChevron={false} + /> + </ItemGroup> </ItemList> ); }); - diff --git a/apps/ui/sources/components/settings/server/sections/ActiveSelectionMachinesSection.tsx b/apps/ui/sources/components/settings/server/sections/ActiveSelectionMachinesSection.tsx index 5a05eb832..0f4b36ddd 100644 --- a/apps/ui/sources/components/settings/server/sections/ActiveSelectionMachinesSection.tsx +++ b/apps/ui/sources/components/settings/server/sections/ActiveSelectionMachinesSection.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { Text as RNText, View } from 'react-native'; +import { View } from 'react-native'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; @@ -11,6 +11,8 @@ import { t } from '@/text'; import type { Machine } from '@/sync/domains/state/storageTypes'; import type { ActiveSelectionMachineGroup } from '../hooks/useActiveSelectionMachineGroups'; +import { Text as RNText } from '@/components/ui/text/Text'; + type ThemeColors = Readonly<{ textSecondary: string; diff --git a/apps/ui/sources/components/settings/server/sections/AddTargetsSection.tsx b/apps/ui/sources/components/settings/server/sections/AddTargetsSection.tsx index ee0f96151..f396fd487 100644 --- a/apps/ui/sources/components/settings/server/sections/AddTargetsSection.tsx +++ b/apps/ui/sources/components/settings/server/sections/AddTargetsSection.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; -import { View, TextInput } from 'react-native'; +import { View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { t } from '@/text'; import type { ServerProfile } from '@/sync/domains/server/serverProfiles'; import { toServerUrlDisplay } from '@/sync/domains/server/url/serverUrlDisplay'; diff --git a/apps/ui/sources/components/settings/session/PermissionsSettingsView.tsx b/apps/ui/sources/components/settings/session/PermissionsSettingsView.tsx index 5f2c8ecb4..b3a14992c 100644 --- a/apps/ui/sources/components/settings/session/PermissionsSettingsView.tsx +++ b/apps/ui/sources/components/settings/session/PermissionsSettingsView.tsx @@ -82,7 +82,7 @@ export const PermissionsSettingsView = React.memo(function PermissionsSettingsVi popoverBoundaryRef={popoverBoundaryRef} itemTrigger={{ title: t('settingsSession.defaultPermissions.applyPermissionChangesTitle'), - icon: <Ionicons name="shield-checkmark-outline" size={29} color="#34C759" />, + icon: <Ionicons name="shield-checkmark-outline" size={29} color={theme.colors.success} />, // Keep the compact label as a fallback; selected option subtitle will override by default. subtitle: applyTimingLabel, }} diff --git a/apps/ui/sources/components/settings/session/ToolRenderingSettingsView.tsx b/apps/ui/sources/components/settings/session/ToolRenderingSettingsView.tsx index 199828d93..bb02aa541 100644 --- a/apps/ui/sources/components/settings/session/ToolRenderingSettingsView.tsx +++ b/apps/ui/sources/components/settings/session/ToolRenderingSettingsView.tsx @@ -125,7 +125,7 @@ export const ToolRenderingSettingsView = React.memo(function ToolRenderingSettin popoverBoundaryRef={popoverBoundaryRef} itemTrigger={{ title: t('settingsSession.toolRendering.defaultToolDetailLevelTitle'), - icon: <Ionicons name="construct-outline" size={29} color="#007AFF" />, + icon: <Ionicons name="construct-outline" size={29} color={theme.colors.accent.blue} />, // Preserve the compact label as fallback; selected option subtitle will override by default. subtitle: (() => { const key = TOOL_DETAIL_LEVEL_OPTIONS.find((opt) => opt.key === toolViewDetailLevelDefault)?.titleKey; @@ -161,7 +161,7 @@ export const ToolRenderingSettingsView = React.memo(function ToolRenderingSettin popoverBoundaryRef={popoverBoundaryRef} itemTrigger={{ title: t('settingsSession.toolRendering.localControlDefaultTitle'), - icon: <Ionicons name="shield-outline" size={29} color="#FF9500" />, + icon: <Ionicons name="shield-outline" size={29} color={theme.colors.accent.orange} />, subtitle: (() => { const key = TOOL_DETAIL_LEVEL_OPTIONS.find((opt) => opt.key === toolViewDetailLevelDefaultLocalControl)?.titleKey; return key ? tToolDetail(key) : String(toolViewDetailLevelDefaultLocalControl); @@ -186,7 +186,7 @@ export const ToolRenderingSettingsView = React.memo(function ToolRenderingSettin <Item title={t('settingsSession.toolRendering.showDebugByDefaultTitle')} subtitle={t('settingsSession.toolRendering.showDebugByDefaultSubtitle')} - icon={<Ionicons name="code-slash-outline" size={29} color="#5856D6" />} + icon={<Ionicons name="code-slash-outline" size={29} color={theme.colors.accent.indigo} />} rightElement={<Switch value={toolViewShowDebugByDefault} onValueChange={setToolViewShowDebugByDefault} />} showChevron={false} onPress={() => setToolViewShowDebugByDefault(!toolViewShowDebugByDefault)} diff --git a/apps/ui/sources/components/settings/sourceControl/SourceControlSettingsView.test.tsx b/apps/ui/sources/components/settings/sourceControl/SourceControlSettingsView.test.tsx index 86282cbad..451c73101 100644 --- a/apps/ui/sources/components/settings/sourceControl/SourceControlSettingsView.test.tsx +++ b/apps/ui/sources/components/settings/sourceControl/SourceControlSettingsView.test.tsx @@ -17,21 +17,7 @@ const setScmCommitMessageGeneratorInstructions = vi.fn(); const modalPrompt = vi.fn(); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TextInput: 'TextInput', -})); - -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - textSecondary: '#999', - }, - }, - }), -})); +vi.mock('react-native', async () => await import('@/dev/reactNativeStub')); vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', diff --git a/apps/ui/sources/components/settings/sourceControl/SourceControlSettingsView.tsx b/apps/ui/sources/components/settings/sourceControl/SourceControlSettingsView.tsx index 15034c2fa..e89efa601 100644 --- a/apps/ui/sources/components/settings/sourceControl/SourceControlSettingsView.tsx +++ b/apps/ui/sources/components/settings/sourceControl/SourceControlSettingsView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { TextInput, View } from 'react-native'; +import { View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/ui/lists/Item'; @@ -17,6 +17,8 @@ import type { ScmPushRejectPolicy, ScmRemoteConfirmPolicy, } from '@/scm/settings/preferences'; +import { TextInput } from '@/components/ui/text/Text'; + type IoniconName = React.ComponentProps<typeof Ionicons>['name']; @@ -210,7 +212,7 @@ export const SourceControlSettingsView = React.memo(function SourceControlSettin title={option.title} subtitle={option.subtitle} icon={renderIcon(option.iconName)} - rightElement={scmCommitStrategy === option.id ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={scmCommitStrategy === option.id ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setScmCommitStrategy(option.id)} showChevron={false} /> @@ -227,7 +229,7 @@ export const SourceControlSettingsView = React.memo(function SourceControlSettin title={option.title} subtitle={option.subtitle} icon={renderIcon(option.iconName)} - rightElement={scmGitRepoPreferredBackend === option.id ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={scmGitRepoPreferredBackend === option.id ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setScmGitRepoPreferredBackend(option.id)} showChevron={false} /> @@ -244,7 +246,7 @@ export const SourceControlSettingsView = React.memo(function SourceControlSettin title={option.title} subtitle={option.subtitle} icon={renderIcon(option.iconName)} - rightElement={scmRemoteConfirmPolicy === option.id ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={scmRemoteConfirmPolicy === option.id ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setScmRemoteConfirmPolicy(option.id)} showChevron={false} /> @@ -261,7 +263,7 @@ export const SourceControlSettingsView = React.memo(function SourceControlSettin title={option.title} subtitle={option.subtitle} icon={renderIcon(option.iconName)} - rightElement={scmPushRejectPolicy === option.id ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={scmPushRejectPolicy === option.id ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setScmPushRejectPolicy(option.id)} showChevron={false} /> @@ -276,7 +278,7 @@ export const SourceControlSettingsView = React.memo(function SourceControlSettin title="Commit message generator" subtitle={effectiveCommitMessageGeneratorEnabled ? 'Enabled' : 'Disabled'} icon={renderIcon('sparkles-outline')} - rightElement={effectiveCommitMessageGeneratorEnabled ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={effectiveCommitMessageGeneratorEnabled ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setScmCommitMessageGeneratorEnabled(!effectiveCommitMessageGeneratorEnabled)} showChevron={false} /> @@ -327,7 +329,7 @@ export const SourceControlSettingsView = React.memo(function SourceControlSettin title="Include Co-Authored-By" subtitle={effectiveIncludeCoAuthoredBy ? 'Enabled' : 'Disabled'} icon={renderIcon('people-outline')} - rightElement={effectiveIncludeCoAuthoredBy ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={effectiveIncludeCoAuthoredBy ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setScmIncludeCoAuthoredBy(!effectiveIncludeCoAuthoredBy)} showChevron={false} /> @@ -343,7 +345,7 @@ export const SourceControlSettingsView = React.memo(function SourceControlSettin title={option.title} subtitle={option.subtitle} icon={renderIcon(option.iconName)} - rightElement={effectiveFilesDiffSyntaxHighlightingMode === option.id ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={effectiveFilesDiffSyntaxHighlightingMode === option.id ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setFilesDiffSyntaxHighlightingMode(option.id)} showChevron={false} /> @@ -354,7 +356,7 @@ export const SourceControlSettingsView = React.memo(function SourceControlSettin title={option.title} subtitle={option.subtitle} icon={renderIcon(option.iconName)} - rightElement={effectiveFilesChangedFilesRowDensity === option.id ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + rightElement={effectiveFilesChangedFilesRowDensity === option.id ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null} onPress={() => setFilesChangedFilesRowDensity(option.id)} showChevron={false} /> @@ -376,7 +378,7 @@ export const SourceControlSettingsView = React.memo(function SourceControlSettin icon={renderIcon(option.iconName)} rightElement={ currentDiffModeByBackend[plugin.backendId] === option.id - ? <Ionicons name="checkmark" size={20} color="#007AFF" /> + ? <Ionicons name="checkmark" size={20} color={theme.colors.accent.blue} /> : null } onPress={() => { diff --git a/apps/ui/sources/components/settings/subAgent/SubAgentSettingsView.test.tsx b/apps/ui/sources/components/settings/subAgent/SubAgentSettingsView.test.tsx index a96dff24b..a77011d92 100644 --- a/apps/ui/sources/components/settings/subAgent/SubAgentSettingsView.test.tsx +++ b/apps/ui/sources/components/settings/subAgent/SubAgentSettingsView.test.tsx @@ -16,24 +16,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - text: '#111111', - textSecondary: '#666666', - divider: '#e5e5e5', - surface: '#ffffff', - surfaceHigh: '#fafafa', - }, - }, - }), - StyleSheet: { - create: (styles: any) => styles, - absoluteFillObject: {}, - }, -})); - vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); @@ -58,8 +40,9 @@ vi.mock('@/components/ui/forms/Switch', () => ({ Switch: 'Switch', })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: 'Text', + TextInput: 'TextInput', })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/components/settings/subAgent/SubAgentSettingsView.tsx b/apps/ui/sources/components/settings/subAgent/SubAgentSettingsView.tsx index d166bd275..83f0a9529 100644 --- a/apps/ui/sources/components/settings/subAgent/SubAgentSettingsView.tsx +++ b/apps/ui/sources/components/settings/subAgent/SubAgentSettingsView.tsx @@ -9,7 +9,7 @@ import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { ItemList } from '@/components/ui/lists/ItemList'; import { Switch } from '@/components/ui/forms/Switch'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import { Modal } from '@/modal'; import { randomUUID } from '@/platform/randomUUID'; @@ -127,7 +127,7 @@ export const SubAgentSettingsView = React.memo(function SubAgentSettingsView() { <Item title="Enable Execution Runs" subtitle="Open Features settings" - icon={<Ionicons name="flask-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="flask-outline" size={29} color={theme.colors.accent.orange} />} onPress={() => router.push('/(app)/settings/features')} /> </ItemGroup> @@ -144,7 +144,7 @@ export const SubAgentSettingsView = React.memo(function SubAgentSettingsView() { <Item title="Enable guidance injection" subtitle={enabled === true ? 'Enabled' : 'Disabled'} - icon={<Ionicons name="sparkles-outline" size={29} color="#FF9500" />} + icon={<Ionicons name="sparkles-outline" size={29} color={theme.colors.accent.orange} />} rightElement={<Switch value={enabled === true} onValueChange={(v) => setEnabled(v as any)} />} showChevron={false} onPress={() => setEnabled((enabled !== true) as any)} diff --git a/apps/ui/sources/components/settings/subAgent/guidance/subAgentGuidanceRuleEditorModal.test.tsx b/apps/ui/sources/components/settings/subAgent/guidance/subAgentGuidanceRuleEditorModal.test.tsx index c70210521..a2deda40a 100644 --- a/apps/ui/sources/components/settings/subAgent/guidance/subAgentGuidanceRuleEditorModal.test.tsx +++ b/apps/ui/sources/components/settings/subAgent/guidance/subAgentGuidanceRuleEditorModal.test.tsx @@ -18,24 +18,6 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - textSecondary: '#999', - text: '#111', - textLink: '#00f', - textDestructive: '#f00', - surface: '#fff', - surfaceHigh: '#fafafa', - divider: '#eee', - input: { background: '#f7f7f7', placeholder: '#aaa' }, - shadow: { color: '#000' }, - }, - }, - }), -})); - vi.mock('@/components/ui/lists/Item', () => ({ Item: (props: any) => React.createElement('Item', props), })); @@ -48,8 +30,9 @@ vi.mock('@/components/ui/forms/Switch', () => ({ Switch: (props: any) => React.createElement('Switch', props), })); -vi.mock('@/components/ui/text/StyledText', () => ({ +vi.mock('@/components/ui/text/Text', () => ({ Text: (props: any) => React.createElement('Text', props, props.children), + TextInput: 'TextInput', })); vi.mock('@/components/ui/buttons/RoundButton', () => ({ @@ -61,6 +44,7 @@ vi.mock('@/agents/hooks/useEnabledAgentIds', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: [], getAgentCore: () => ({ displayNameKey: 'agent.claude' }), isAgentId: () => true, DEFAULT_AGENT_ID: 'claude', @@ -80,6 +64,7 @@ vi.mock('@/sync/domains/server/serverRuntime', () => ({ vi.mock('@/sync/store/hooks', () => ({ useAllMachines: () => [], + useLocalSetting: () => 1, })); vi.mock('@/sync/domains/state/storage', () => ({ diff --git a/apps/ui/sources/components/settings/subAgent/guidance/subAgentGuidanceRuleEditorModal.tsx b/apps/ui/sources/components/settings/subAgent/guidance/subAgentGuidanceRuleEditorModal.tsx index 2ed1d8ed0..75670acce 100644 --- a/apps/ui/sources/components/settings/subAgent/guidance/subAgentGuidanceRuleEditorModal.tsx +++ b/apps/ui/sources/components/settings/subAgent/guidance/subAgentGuidanceRuleEditorModal.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, ScrollView, TextInput, View, useWindowDimensions } from 'react-native'; +import { Platform, ScrollView, View, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; @@ -12,7 +12,7 @@ import { Item } from '@/components/ui/lists/Item'; import { RoundButton } from '@/components/ui/buttons/RoundButton'; import { DropdownMenu } from '@/components/ui/forms/dropdown/DropdownMenu'; import { Switch } from '@/components/ui/forms/Switch'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text, TextInput } from '@/components/ui/text/Text'; import { Typography } from '@/constants/Typography'; import type { ModelMode } from '@/sync/domains/permissions/permissionTypes'; import type { ExecutionRunsGuidanceEntry } from '@/sync/domains/settings/executionRunsGuidance'; @@ -182,7 +182,7 @@ export function SubAgentGuidanceRuleEditorModal(props: Readonly<{ <Item title="Enabled" subtitle={enabled ? 'Enabled' : 'Disabled'} - icon={<Ionicons name="sparkles-outline" size={24} color="#FF9500" />} + icon={<Ionicons name="sparkles-outline" size={24} color={theme.colors.accent.orange} />} rightElement={<Switch value={enabled} onValueChange={setEnabled} />} showChevron={false} showDivider={false} diff --git a/apps/ui/sources/components/tools/renderers/fileOps/CodeSearchView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/CodeSearchView.test.tsx index 36ac7615a..62f364139 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/CodeSearchView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/CodeSearchView.test.tsx @@ -5,14 +5,6 @@ import { collectHostText, makeToolCall, makeToolViewProps } from '../../shell/vi (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), diff --git a/apps/ui/sources/components/tools/renderers/fileOps/CodeSearchView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/CodeSearchView.tsx index 04bd1830e..583ae689b 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/CodeSearchView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/CodeSearchView.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { coerceToolResultRecord } from '../../legacy/coerceToolResultRecord'; +import { Text } from '@/components/ui/text/Text'; + function asRecord(value: unknown): Record<string, unknown> | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; diff --git a/apps/ui/sources/components/tools/renderers/fileOps/DeleteView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/DeleteView.test.tsx index d26c02f1c..343aaae28 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/DeleteView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/DeleteView.test.tsx @@ -6,16 +6,6 @@ import { expectListSummary, makeCompletedTool } from '../core/listView.testHelpe (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => ({ theme: { colors: { text: '#000', textSecondary: '#999', surfaceHigh: '#eee' } } }), -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/DeleteView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/DeleteView.tsx index 3aed53f21..609c53a3b 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/DeleteView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/DeleteView.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { maybeParseJson } from '../../normalization/parse/parseJson'; +import { Text } from '@/components/ui/text/Text'; + function asRecord(value: unknown): Record<string, unknown> | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; diff --git a/apps/ui/sources/components/tools/renderers/fileOps/DiffView.controlsRow.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/DiffView.controlsRow.test.tsx index 8efbd4ec8..608b3d8fc 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/DiffView.controlsRow.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/DiffView.controlsRow.test.tsx @@ -5,33 +5,6 @@ import { makeToolCall, makeToolViewProps, findPressableByText } from '../../shel (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { - create: (fn: any) => fn({ - colors: { - text: '#111', - textSecondary: '#666', - textLink: '#08f', - divider: '#ddd', - surface: '#fff', - surfaceHigh: '#f5f5f5', - surfaceHighest: '#fafafa', - diff: { - addedBg: '#e6ffed', - addedBorder: '#b7eb8f', - addedText: '#135200', - removedBg: '#ffecec', - removedBorder: '#ffa39e', - removedText: '#a8071a', - }, - box: { - warning: { background: '#fff7e6', border: '#ffd591', text: '#ad6800' }, - }, - }, - }), - }, -})); - vi.mock('@/sync/domains/state/storage', () => ({ useSetting: (key: string) => { if (key === 'showLineNumbersInToolViews') return false; diff --git a/apps/ui/sources/components/tools/renderers/fileOps/DiffView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/DiffView.test.tsx index 7c248d372..98dfedc02 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/DiffView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/DiffView.test.tsx @@ -10,16 +10,6 @@ import { ToolHeaderActionsContext } from '../../shell/presentation/ToolHeaderAct const diffSpy = vi.fn(); const codeLinesSpy = vi.fn(); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - Pressable: 'Pressable', - ScrollView: 'ScrollView', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), diff --git a/apps/ui/sources/components/tools/renderers/fileOps/DiffView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/DiffView.tsx index 8ba7233f8..073058092 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/DiffView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/DiffView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollView, View, Text, Pressable, Platform } from 'react-native'; +import { ScrollView, View, Pressable, Platform } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; @@ -10,6 +10,8 @@ import { buildCodeLinesFromUnifiedDiff } from '@/components/ui/code/model/buildC import { buildDiffBlocks, buildDiffFileEntries, type DiffFileEntry } from '@/components/ui/code/model/diff/diffViewModel'; import { t } from '@/text'; import { useToolHeaderActions } from '../../shell/presentation/ToolHeaderActionsContext'; +import { Text } from '@/components/ui/text/Text'; + function UnifiedDiffInlineView(props: Readonly<{ unifiedDiff: string; diff --git a/apps/ui/sources/components/tools/renderers/fileOps/EditView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/EditView.tsx index b88f96817..6222a8e3d 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/EditView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/EditView.tsx @@ -4,7 +4,9 @@ import { ToolViewProps } from '../core/_registry'; import { ToolDiffView } from '@/components/tools/shell/presentation/ToolDiffView'; import { trimIdent } from '@/utils/strings/trimIdent'; import { useSetting } from '@/sync/domains/state/storage'; -import { Text } from 'react-native'; + +import { Text } from '@/components/ui/text/Text'; + function extractEditStrings(input: any): { old: string; next: string } { // 1) ACP nested format: tool.input.toolCall.content[0] diff --git a/apps/ui/sources/components/tools/renderers/fileOps/GlobView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/GlobView.test.tsx index ce86c123a..e8c81aca7 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/GlobView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/GlobView.test.tsx @@ -6,16 +6,6 @@ import { expectListSummary, makeCompletedTool } from '../core/listView.testHelpe (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => ({ theme: { colors: { textSecondary: '#999' } } }), -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/GlobView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/GlobView.tsx index b420875d0..1cc357deb 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/GlobView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/GlobView.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import type { ToolViewProps } from '../core/_registry'; import { coerceToolResultRecord } from '../../legacy/coerceToolResultRecord'; +import { Text } from '@/components/ui/text/Text'; + function getMatches(result: unknown): string[] { const record = coerceToolResultRecord(result); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/GrepView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/GrepView.test.tsx index edca7e116..dbbf2b415 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/GrepView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/GrepView.test.tsx @@ -6,15 +6,6 @@ import { expectListSummary, makeCompletedTool } from '../core/listView.testHelpe (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/GrepView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/GrepView.tsx index 02ba86f4b..733118243 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/GrepView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/GrepView.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import type { ToolViewProps } from '../core/_registry'; import { coerceToolResultRecord } from '../../legacy/coerceToolResultRecord'; +import { Text } from '@/components/ui/text/Text'; + type GrepMatch = { filePath?: string; line?: number; excerpt?: string }; diff --git a/apps/ui/sources/components/tools/renderers/fileOps/LSView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/LSView.test.tsx index 219506efa..73f1bd534 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/LSView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/LSView.test.tsx @@ -6,16 +6,6 @@ import { expectListSummary, makeCompletedTool } from '../core/listView.testHelpe (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => ({ theme: { colors: { textSecondary: '#999' } } }), -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/LSView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/LSView.tsx index 6f73dc16a..906db5863 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/LSView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/LSView.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { maybeParseJson } from '../../normalization/parse/parseJson'; +import { Text } from '@/components/ui/text/Text'; + function asRecord(value: unknown): Record<string, unknown> | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; diff --git a/apps/ui/sources/components/tools/renderers/fileOps/MultiEditView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/MultiEditView.test.tsx index c763b8270..02b8837e1 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/MultiEditView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/MultiEditView.test.tsx @@ -6,15 +6,6 @@ import { collectHostText, makeToolCall, makeToolViewProps } from '../../shell/vi (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/MultiEditView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/MultiEditView.tsx index f87925080..57ec6ccfc 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/MultiEditView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/MultiEditView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { ToolViewProps } from '../core/_registry'; @@ -8,6 +8,8 @@ import { knownTools } from '../../catalog'; import { trimIdent } from '@/utils/strings/trimIdent'; import { useSetting } from '@/sync/domains/state/storage'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + export const MultiEditView = React.memo<ToolViewProps>(({ tool, detailLevel }) => { const showLineNumbersInToolViews = useSetting('showLineNumbersInToolViews'); @@ -83,7 +85,7 @@ export const MultiEditView = React.memo<ToolViewProps>(({ tool, detailLevel }) = ); }); -const styles = StyleSheet.create({ +const styles = StyleSheet.create((theme) => ({ editHeader: { flexDirection: 'row', alignItems: 'center', @@ -92,10 +94,10 @@ const styles = StyleSheet.create({ editNumber: { fontSize: 14, fontWeight: '600', - color: '#5856D6', + color: theme.colors.accent.indigo, }, replaceAllBadge: { - backgroundColor: '#5856D6', + backgroundColor: theme.colors.accent.indigo, paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12, @@ -103,7 +105,7 @@ const styles = StyleSheet.create({ }, replaceAllText: { fontSize: 12, - color: '#fff', + color: theme.colors.button.primary.tint, fontWeight: '600', }, separator: { @@ -112,7 +114,7 @@ const styles = StyleSheet.create({ more: { marginTop: 8, fontSize: 12, - color: '#8E8E93', + color: theme.colors.textSecondary, fontFamily: 'Menlo', }, -}); +})); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/PatchView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/PatchView.test.tsx index 9620bd57d..d6e7ca66a 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/PatchView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/PatchView.test.tsx @@ -7,16 +7,6 @@ import { makeCompletedTool, normalizedHostText } from '../core/truncationView.te (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => ({ theme: { colors: { textSecondary: '#999', surfaceHigh: '#000' } } }), -})); - vi.mock('@expo/vector-icons', () => ({ Octicons: 'Octicons', })); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/PatchView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/PatchView.tsx index e1853e769..8e99d4714 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/PatchView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/PatchView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Octicons } from '@expo/vector-icons'; import type { ToolViewProps } from '../core/_registry'; @@ -7,6 +7,8 @@ import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { resolvePath } from '@/utils/path/pathUtils'; import { ToolDiffView } from '@/components/tools/shell/presentation/ToolDiffView'; import { useSetting } from '@/sync/domains/state/storage'; +import { Text } from '@/components/ui/text/Text'; + type PatchChange = { filePath: string; diff --git a/apps/ui/sources/components/tools/renderers/fileOps/ReadView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/ReadView.test.tsx index a999feff2..262228417 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/ReadView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/ReadView.test.tsx @@ -7,15 +7,6 @@ import { makeCompletedTool, normalizedHostText } from '../core/truncationView.te (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('@/components/ui/media/CodeView', () => ({ CodeView: ({ code }: any) => React.createElement('CodeView', { code }), })); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/ReadView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/ReadView.tsx index a9624f5b6..320b991d8 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/ReadView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/ReadView.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import type { ToolViewProps } from '../core/_registry'; import { CodeView } from '@/components/ui/media/CodeView'; import { maybeParseJson } from '../../normalization/parse/parseJson'; +import { Text } from '@/components/ui/text/Text'; + function extractReadContent(result: unknown): { content: string; numLines?: number } | null { const parsed = maybeParseJson(result); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/WriteView.test.tsx b/apps/ui/sources/components/tools/renderers/fileOps/WriteView.test.tsx index ba1f8a3b0..29abbda66 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/WriteView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/WriteView.test.tsx @@ -6,15 +6,6 @@ import { collectHostText, makeToolCall, makeToolViewProps } from '../../shell/vi (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/fileOps/WriteView.tsx b/apps/ui/sources/components/tools/renderers/fileOps/WriteView.tsx index bbb79e088..8d5cb7675 100644 --- a/apps/ui/sources/components/tools/renderers/fileOps/WriteView.tsx +++ b/apps/ui/sources/components/tools/renderers/fileOps/WriteView.tsx @@ -4,7 +4,9 @@ import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { knownTools } from '@/components/tools/catalog'; import { ToolDiffView } from '@/components/tools/shell/presentation/ToolDiffView'; import { useSetting } from '@/sync/domains/state/storage'; -import { Text } from 'react-native'; + +import { Text } from '@/components/ui/text/Text'; + function truncateLines(text: string, maxLines: number): string { const lines = text.replace(/\r\n/g, '\n').split('\n'); diff --git a/apps/ui/sources/components/tools/renderers/system/AcpHistoryImportView.test.tsx b/apps/ui/sources/components/tools/renderers/system/AcpHistoryImportView.test.tsx index 203f9853c..cb34efb1b 100644 --- a/apps/ui/sources/components/tools/renderers/system/AcpHistoryImportView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/system/AcpHistoryImportView.test.tsx @@ -20,29 +20,6 @@ vi.mock('@/modal', () => ({ }, })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => ({ - theme: { - colors: { - button: { primary: { background: '#00f', tint: '#fff' } }, - divider: '#ddd', - text: '#000', - textSecondary: '#666', - surfaceHigh: '#eee', - surfaceHighest: '#f3f3f3', - }, - }, - }), -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/system/AcpHistoryImportView.tsx b/apps/ui/sources/components/tools/renderers/system/AcpHistoryImportView.tsx index ffb32553a..fad0a6d08 100644 --- a/apps/ui/sources/components/tools/renderers/system/AcpHistoryImportView.tsx +++ b/apps/ui/sources/components/tools/renderers/system/AcpHistoryImportView.tsx @@ -1,11 +1,13 @@ import * as React from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { sessionAllow, sessionDeny } from '@/sync/ops'; import { Modal } from '@/modal'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + type HistoryPreviewItem = { role?: string; text?: string }; diff --git a/apps/ui/sources/components/tools/renderers/system/MCPToolView.test.tsx b/apps/ui/sources/components/tools/renderers/system/MCPToolView.test.tsx index 380f00cd7..2e6f2b639 100644 --- a/apps/ui/sources/components/tools/renderers/system/MCPToolView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/system/MCPToolView.test.tsx @@ -6,15 +6,6 @@ import { collectHostText, makeToolCall, makeToolViewProps } from '../../shell/vi (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/system/MCPToolView.tsx b/apps/ui/sources/components/tools/renderers/system/MCPToolView.tsx index 708ff26ce..f5260179e 100644 --- a/apps/ui/sources/components/tools/renderers/system/MCPToolView.tsx +++ b/apps/ui/sources/components/tools/renderers/system/MCPToolView.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { CodeView } from '@/components/ui/media/CodeView'; import { maybeParseJson } from '../../normalization/parse/parseJson'; +import { Text } from '@/components/ui/text/Text'; + /** * Converts snake_case string to PascalCase with spaces diff --git a/apps/ui/sources/components/tools/renderers/system/StructuredResultView.tsx b/apps/ui/sources/components/tools/renderers/system/StructuredResultView.tsx index bf0a5fb0e..1c37c21b4 100644 --- a/apps/ui/sources/components/tools/renderers/system/StructuredResultView.tsx +++ b/apps/ui/sources/components/tools/renderers/system/StructuredResultView.tsx @@ -1,11 +1,13 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { CodeView } from '@/components/ui/media/CodeView'; import { maybeParseJson } from '../../normalization/parse/parseJson'; import { tailTextWithEllipsis } from '../../normalization/parse/stdStreams'; +import { Text } from '@/components/ui/text/Text'; + function truncate(text: string, maxChars: number): string { if (text.length <= maxChars) return text; diff --git a/apps/ui/sources/components/tools/renderers/system/UnknownToolView.tsx b/apps/ui/sources/components/tools/renderers/system/UnknownToolView.tsx index 871788f07..efde32f09 100644 --- a/apps/ui/sources/components/tools/renderers/system/UnknownToolView.tsx +++ b/apps/ui/sources/components/tools/renderers/system/UnknownToolView.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { CodeView } from '@/components/ui/media/CodeView'; import { maybeParseJson } from '../../normalization/parse/parseJson'; +import { Text } from '@/components/ui/text/Text'; + type UnknownRecord = Record<string, unknown>; diff --git a/apps/ui/sources/components/tools/renderers/system/WorkspaceIndexingPermissionView.test.tsx b/apps/ui/sources/components/tools/renderers/system/WorkspaceIndexingPermissionView.test.tsx index 0ab82a4b3..ed75d6ac3 100644 --- a/apps/ui/sources/components/tools/renderers/system/WorkspaceIndexingPermissionView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/system/WorkspaceIndexingPermissionView.test.tsx @@ -5,15 +5,6 @@ import type { ToolCall } from '@/sync/domains/messages/messageTypes'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); @@ -90,4 +81,3 @@ describe('WorkspaceIndexingPermissionView', () => { expect(joined).toContain('Choose an option below to continue.'); }); }); - diff --git a/apps/ui/sources/components/tools/renderers/system/WorkspaceIndexingPermissionView.tsx b/apps/ui/sources/components/tools/renderers/system/WorkspaceIndexingPermissionView.tsx index fe0cc29f1..7654ec35d 100644 --- a/apps/ui/sources/components/tools/renderers/system/WorkspaceIndexingPermissionView.tsx +++ b/apps/ui/sources/components/tools/renderers/system/WorkspaceIndexingPermissionView.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; +import { Text } from '@/components/ui/text/Text'; + type IndexingOption = { id?: string; name?: string; kind?: string }; diff --git a/apps/ui/sources/components/tools/renderers/web/WebFetchView.test.tsx b/apps/ui/sources/components/tools/renderers/web/WebFetchView.test.tsx index 8188fefd7..8e6c55d96 100644 --- a/apps/ui/sources/components/tools/renderers/web/WebFetchView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/web/WebFetchView.test.tsx @@ -7,15 +7,6 @@ import { makeCompletedTool, normalizedHostText } from '../core/truncationView.te (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('@/components/ui/media/CodeView', () => ({ CodeView: ({ code }: any) => React.createElement('CodeView', { code }), })); diff --git a/apps/ui/sources/components/tools/renderers/web/WebFetchView.tsx b/apps/ui/sources/components/tools/renderers/web/WebFetchView.tsx index e5a76b680..34ba44f38 100644 --- a/apps/ui/sources/components/tools/renderers/web/WebFetchView.tsx +++ b/apps/ui/sources/components/tools/renderers/web/WebFetchView.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { CodeView } from '@/components/ui/media/CodeView'; import { maybeParseJson } from '../../normalization/parse/parseJson'; +import { Text } from '@/components/ui/text/Text'; + function asRecord(value: unknown): Record<string, unknown> | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; diff --git a/apps/ui/sources/components/tools/renderers/web/WebSearchView.test.tsx b/apps/ui/sources/components/tools/renderers/web/WebSearchView.test.tsx index 87fbfd93c..aa4b12dba 100644 --- a/apps/ui/sources/components/tools/renderers/web/WebSearchView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/web/WebSearchView.test.tsx @@ -7,15 +7,6 @@ import { makeCompletedTool, normalizedHostText } from '../core/truncationView.te (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/web/WebSearchView.tsx b/apps/ui/sources/components/tools/renderers/web/WebSearchView.tsx index 047146aa8..2bdf3b54c 100644 --- a/apps/ui/sources/components/tools/renderers/web/WebSearchView.tsx +++ b/apps/ui/sources/components/tools/renderers/web/WebSearchView.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; import { maybeParseJson } from '../../normalization/parse/parseJson'; +import { Text } from '@/components/ui/text/Text'; + type WebResult = { title?: string; url?: string; snippet?: string }; diff --git a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.test.ts b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.test.ts index 4b4d1a3c9..e743aba2a 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.test.ts +++ b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.test.ts @@ -22,29 +22,6 @@ vi.mock('@/modal', () => ({ }, })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => ({ - theme: { - colors: { - button: { primary: { background: '#00f', tint: '#fff' } }, - divider: '#ddd', - text: '#000', - textSecondary: '#666', - surface: '#fff', - input: { background: '#fff', placeholder: '#aaa', text: '#000' }, - }, - }, - }), -})); - vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); diff --git a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx index 87993c1d2..4dc3c1c1d 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; @@ -9,6 +9,8 @@ import { sync } from '@/sync/sync'; import { Modal } from '@/modal'; import { t } from '@/text'; import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/ui/text/Text'; + interface QuestionOption { label: string; @@ -365,7 +367,7 @@ export const AskUserQuestionView = React.memo<ToolViewProps>(({ tool, sessionId, isSelected && styles.checkboxOuterSelected, ]}> {isSelected && ( - <Ionicons name="checkmark" size={14} color="#fff" /> + <Ionicons name="checkmark" size={14} color={theme.colors.button.primary.tint} /> )} </View> ) : ( diff --git a/apps/ui/sources/components/tools/renderers/workflow/ChangeTitleView.test.tsx b/apps/ui/sources/components/tools/renderers/workflow/ChangeTitleView.test.tsx index 24f481323..c49fde3e9 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/ChangeTitleView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/ChangeTitleView.test.tsx @@ -5,15 +5,6 @@ import { collectHostText, makeToolCall, makeToolViewProps } from '../../shell/vi (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/workflow/ChangeTitleView.tsx b/apps/ui/sources/components/tools/renderers/workflow/ChangeTitleView.tsx index 5c236f965..e258af821 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/ChangeTitleView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/ChangeTitleView.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; +import { Text } from '@/components/ui/text/Text'; + export const ChangeTitleView = React.memo<ToolViewProps>(({ tool, detailLevel }) => { if (detailLevel === 'title') return null; diff --git a/apps/ui/sources/components/tools/renderers/workflow/EnterPlanModeView.test.tsx b/apps/ui/sources/components/tools/renderers/workflow/EnterPlanModeView.test.tsx index db14eba93..f7c73269d 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/EnterPlanModeView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/EnterPlanModeView.test.tsx @@ -6,15 +6,6 @@ import { collectHostText, makeToolCall, makeToolViewProps } from '../../shell/vi (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/workflow/EnterPlanModeView.tsx b/apps/ui/sources/components/tools/renderers/workflow/EnterPlanModeView.tsx index 392fcfd5d..a25692c7d 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/EnterPlanModeView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/EnterPlanModeView.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; +import { Text } from '@/components/ui/text/Text'; + export const EnterPlanModeView = React.memo<ToolViewProps>(({ detailLevel }) => { if (detailLevel === 'title') return null; diff --git a/apps/ui/sources/components/tools/renderers/workflow/ExitPlanToolView.test.ts b/apps/ui/sources/components/tools/renderers/workflow/ExitPlanToolView.test.ts index fde05c07f..b20802dff 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/ExitPlanToolView.test.ts +++ b/apps/ui/sources/components/tools/renderers/workflow/ExitPlanToolView.test.ts @@ -22,28 +22,6 @@ vi.mock('@/modal', () => ({ }, })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', - TextInput: 'TextInput', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => ({ - theme: { - colors: { - button: { primary: { background: '#00f', tint: '#fff' } }, - divider: '#ddd', - text: '#000', - textSecondary: '#666', - }, - }, - }), -})); - vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); @@ -161,23 +139,28 @@ describe('ExitPlanToolView', () => { }); it('shows an error when requesting plan changes fails', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); sessionDeny.mockRejectedValueOnce(new Error('network')); - const tree = await renderView(makeRunningTool()); + try { + const tree = await renderView(makeRunningTool()); - await act(async () => { - await tree.root.findByProps({ testID: 'exit-plan-request-changes' }).props.onPress(); - }); + await act(async () => { + await tree.root.findByProps({ testID: 'exit-plan-request-changes' }).props.onPress(); + }); - await act(async () => { - tree.root.findByProps({ testID: 'exit-plan-request-changes-input' }).props.onChangeText('Please change step 2'); - }); + await act(async () => { + tree.root.findByProps({ testID: 'exit-plan-request-changes-input' }).props.onChangeText('Please change step 2'); + }); - await act(async () => { - await tree.root.findByProps({ testID: 'exit-plan-request-changes-send' }).props.onPress(); - }); + await act(async () => { + await tree.root.findByProps({ testID: 'exit-plan-request-changes-send' }).props.onPress(); + }); - expect(modalAlert).toHaveBeenCalledWith('common.error', 'tools.exitPlanMode.requestChangesFailed'); + expect(modalAlert).toHaveBeenCalledWith('common.error', 'tools.exitPlanMode.requestChangesFailed'); + } finally { + consoleErrorSpy.mockRestore(); + } }); it('shows an error when requesting changes is attempted without text', async () => { diff --git a/apps/ui/sources/components/tools/renderers/workflow/ExitPlanToolView.tsx b/apps/ui/sources/components/tools/renderers/workflow/ExitPlanToolView.tsx index bd8f444cf..a7b0ae089 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/ExitPlanToolView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/ExitPlanToolView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator, TextInput } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; @@ -9,6 +9,8 @@ import { sessionAllow, sessionDeny } from '@/sync/ops'; import { Modal } from '@/modal'; import { t } from '@/text'; import { Ionicons } from '@expo/vector-icons'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export const ExitPlanToolView = React.memo<ToolViewProps>(({ tool, sessionId, interaction }) => { const { theme } = useUnistyles(); diff --git a/apps/ui/sources/components/tools/renderers/workflow/SubAgentRunView.test.tsx b/apps/ui/sources/components/tools/renderers/workflow/SubAgentRunView.test.tsx index 8e37d61af..ecc91f5b5 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/SubAgentRunView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/SubAgentRunView.test.tsx @@ -4,31 +4,6 @@ import { describe, expect, it, vi } from 'vitest'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: ({ children, ...props }: any) => React.createElement('View', props, children), - Text: ({ children, ...props }: any) => React.createElement('Text', props, children), -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { - create: (arg: any) => { - const theme = { colors: { surfaceHigh: '#222', textSecondary: '#aaa', text: '#eee' } }; - return typeof arg === 'function' ? arg(theme) : arg; - }, - }, - useUnistyles: () => ({ - theme: { - colors: { - textSecondary: '#aaa', - text: '#eee', - warning: '#f90', - success: '#0a0', - textDestructive: '#a00', - }, - }, - }), -})); - vi.mock('@/components/tools/renderers/system/StructuredResultView', () => ({ StructuredResultView: () => React.createElement('StructuredResultView'), })); diff --git a/apps/ui/sources/components/tools/renderers/workflow/SubAgentRunView.tsx b/apps/ui/sources/components/tools/renderers/workflow/SubAgentRunView.tsx index f7bfbd740..43958be6a 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/SubAgentRunView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/SubAgentRunView.tsx @@ -1,11 +1,13 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '@/components/tools/renderers/core/_registry'; import { StructuredResultView } from '@/components/tools/renderers/system/StructuredResultView'; import type { Message } from '@/sync/domains/messages/messageTypes'; import { TaskLikeSummarySection } from './TaskLikeSummarySection'; +import { Text } from '@/components/ui/text/Text'; + type FindingsDigest = Readonly<{ total: number; diff --git a/apps/ui/sources/components/tools/renderers/workflow/TaskLikeSummarySection.tsx b/apps/ui/sources/components/tools/renderers/workflow/TaskLikeSummarySection.tsx index ee0b48172..4971f7393 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/TaskLikeSummarySection.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/TaskLikeSummarySection.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Text, View, ActivityIndicator, Platform } from 'react-native'; +import { View, ActivityIndicator, Platform } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; @@ -8,6 +8,8 @@ import { ToolSectionView } from '@/components/tools/shell/presentation/ToolSecti import type { Message, ToolCall } from '@/sync/domains/messages/messageTypes'; import type { Metadata } from '@/sync/domains/state/storageTypes'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + interface FilteredTool { tool: ToolCall; diff --git a/apps/ui/sources/components/tools/renderers/workflow/TaskView.test.tsx b/apps/ui/sources/components/tools/renderers/workflow/TaskView.test.tsx index 3b2c64bd0..5221d6ed7 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/TaskView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/TaskView.test.tsx @@ -10,22 +10,27 @@ vi.mock('react-native', () => ({ View: 'View', Text: 'Text', ActivityIndicator: 'ActivityIndicator', - Platform: { OS: 'ios' }, + Platform: { OS: 'ios', select: (options: any) => options?.ios ?? options?.default ?? options?.web ?? null }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, })); -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => ({ - theme: { - colors: { - textSecondary: '#666', - warning: '#f90', - success: '#0a0', - textDestructive: '#a00', - }, +vi.mock('react-native-unistyles', () => { + const theme = { + colors: { + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, + textSecondary: '#666', + warning: '#f90', + success: '#0a0', + textDestructive: '#a00', }, - }), -})); + }; + return { + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input(theme) : input) }, + useUnistyles: () => ({ theme }), + }; +}); vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', diff --git a/apps/ui/sources/components/tools/renderers/workflow/TodoView.test.tsx b/apps/ui/sources/components/tools/renderers/workflow/TodoView.test.tsx index 1fe699089..c0a89b72c 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/TodoView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/TodoView.test.tsx @@ -7,15 +7,6 @@ import { makeCompletedTool, normalizedHostText } from '../core/truncationView.te (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', -})); - -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('../../shell/presentation/ToolSectionView', () => ({ ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), })); diff --git a/apps/ui/sources/components/tools/renderers/workflow/TodoView.tsx b/apps/ui/sources/components/tools/renderers/workflow/TodoView.tsx index e6c2780a7..16ab507e6 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/TodoView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/TodoView.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import type { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; +import { Text } from '@/components/ui/text/Text'; + export interface Todo { content: string; diff --git a/apps/ui/sources/components/tools/shell/permissions/PermissionFooter.tsx b/apps/ui/sources/components/tools/shell/permissions/PermissionFooter.tsx index ba32ea59c..cdccdd8b1 100644 --- a/apps/ui/sources/components/tools/shell/permissions/PermissionFooter.tsx +++ b/apps/ui/sources/components/tools/shell/permissions/PermissionFooter.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator, Platform } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { sessionAbort, sessionAllow, sessionDeny } from '@/sync/ops'; import { sync } from '@/sync/sync'; @@ -11,6 +11,8 @@ import { getPermissionFooterCopy } from '@/agents/catalog/permissionUiCopy'; import { extractShellCommand } from '@/components/tools/normalization/parse/shellCommand'; import { parseParenIdentifier } from '@/components/tools/normalization/parse/parseParenIdentifier'; import { formatPermissionRequestSummary } from '@/components/tools/normalization/policy/permissionSummary'; +import { Text } from '@/components/ui/text/Text'; + interface PermissionFooterProps { permission: { diff --git a/apps/ui/sources/components/tools/shell/presentation/ToolError.tsx b/apps/ui/sources/components/tools/shell/presentation/ToolError.tsx index a9e542116..3d19ebb21 100644 --- a/apps/ui/sources/components/tools/shell/presentation/ToolError.tsx +++ b/apps/ui/sources/components/tools/shell/presentation/ToolError.tsx @@ -1,7 +1,9 @@ -import { Text, View } from "react-native"; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@expo/vector-icons'; import { parseToolUseError } from '@/utils/errors/toolErrorParser'; +import { Text } from '@/components/ui/text/Text'; + export function ToolError(props: { message: string }) { const { theme } = useUnistyles(); diff --git a/apps/ui/sources/components/tools/shell/presentation/ToolHeader.iconGuard.test.tsx b/apps/ui/sources/components/tools/shell/presentation/ToolHeader.iconGuard.test.tsx index e61f0afd3..5836229ab 100644 --- a/apps/ui/sources/components/tools/shell/presentation/ToolHeader.iconGuard.test.tsx +++ b/apps/ui/sources/components/tools/shell/presentation/ToolHeader.iconGuard.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import renderer from 'react-test-renderer'; +import renderer, { act } from 'react-test-renderer'; import { describe, it, expect, vi } from 'vitest'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -16,7 +16,20 @@ describe('ToolHeader', () => { it('does not crash when knownTool.icon is present but not a function', async () => { const { ToolHeader } = await import('./ToolHeader'); const tool = { name: 'test-tool' } as any; - expect(() => renderer.create(<ToolHeader tool={tool} />)).not.toThrow(); + let tree: renderer.ReactTestRenderer | undefined; + let thrown: unknown; + try { + await act(async () => { + tree = renderer.create(<ToolHeader tool={tool} />); + }); + } catch (error) { + thrown = error; + } finally { + act(() => { + tree?.unmount(); + }); + } + + expect(thrown).toBeUndefined(); }); }); - diff --git a/apps/ui/sources/components/tools/shell/presentation/ToolHeader.tsx b/apps/ui/sources/components/tools/shell/presentation/ToolHeader.tsx index a25df8aca..a43956fc2 100644 --- a/apps/ui/sources/components/tools/shell/presentation/ToolHeader.tsx +++ b/apps/ui/sources/components/tools/shell/presentation/ToolHeader.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { ToolCall } from '@/sync/domains/messages/messageTypes'; import { knownTools } from '@/components/tools/catalog'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + interface ToolHeaderProps { tool: ToolCall; diff --git a/apps/ui/sources/components/tools/shell/presentation/ToolSectionView.tsx b/apps/ui/sources/components/tools/shell/presentation/ToolSectionView.tsx index 5458910c8..ad3321496 100644 --- a/apps/ui/sources/components/tools/shell/presentation/ToolSectionView.tsx +++ b/apps/ui/sources/components/tools/shell/presentation/ToolSectionView.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + interface ToolSectionViewProps { title?: string; diff --git a/apps/ui/sources/components/tools/shell/presentation/ToolStatusIndicator.tsx b/apps/ui/sources/components/tools/shell/presentation/ToolStatusIndicator.tsx index 706d10034..bf04e7ef9 100644 --- a/apps/ui/sources/components/tools/shell/presentation/ToolStatusIndicator.tsx +++ b/apps/ui/sources/components/tools/shell/presentation/ToolStatusIndicator.tsx @@ -2,27 +2,28 @@ import * as React from 'react'; import { View, ActivityIndicator } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { ToolCall } from '@/sync/domains/messages/messageTypes'; -import { StyleSheet } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; interface ToolStatusIndicatorProps { tool: ToolCall; } export function ToolStatusIndicator({ tool }: ToolStatusIndicatorProps) { + const { theme } = useUnistyles(); return ( <View style={styles.container}> - <StatusIndicator state={tool.state} /> + <StatusIndicator state={tool.state} theme={theme} /> </View> ); } -function StatusIndicator({ state }: { state: ToolCall['state'] }) { +function StatusIndicator({ state, theme }: { state: ToolCall['state']; theme: any }) { switch (state) { case 'running': - return <ActivityIndicator size="small" color="#007AFF" />; + return <ActivityIndicator size="small" color={theme.colors.accent.blue} />; case 'completed': - return <Ionicons name="checkmark-circle" size={22} color="#34C759" />; + return <Ionicons name="checkmark-circle" size={22} color={theme.colors.success} />; case 'error': - return <Ionicons name="close-circle" size={22} color="#FF3B30" />; + return <Ionicons name="close-circle" size={22} color={theme.colors.warningCritical} />; default: return null; } diff --git a/apps/ui/sources/components/tools/shell/views/ToolFullView.errorObjectResult.test.ts b/apps/ui/sources/components/tools/shell/views/ToolFullView.errorObjectResult.test.ts index 5fc2a08e3..8016d3e22 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolFullView.errorObjectResult.test.ts +++ b/apps/ui/sources/components/tools/shell/views/ToolFullView.errorObjectResult.test.ts @@ -15,10 +15,6 @@ vi.mock('react-native', () => ({ useWindowDimensions: () => ({ width: 800, height: 600 }), })); -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); diff --git a/apps/ui/sources/components/tools/shell/views/ToolFullView.inference.test.ts b/apps/ui/sources/components/tools/shell/views/ToolFullView.inference.test.ts index 822e08cd6..03ca6a732 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolFullView.inference.test.ts +++ b/apps/ui/sources/components/tools/shell/views/ToolFullView.inference.test.ts @@ -13,10 +13,6 @@ vi.mock('react-native-device-info', () => ({ getDeviceType: () => 'Handset', })); -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('@/sync/domains/state/storage', () => ({ useLocalSetting: () => false, useSetting: () => false, diff --git a/apps/ui/sources/components/tools/shell/views/ToolFullView.permissionPending.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolFullView.permissionPending.test.tsx index c146a3f0f..fb36f56fd 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolFullView.permissionPending.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolFullView.permissionPending.test.tsx @@ -23,10 +23,6 @@ vi.mock('react-native', () => ({ useWindowDimensions: () => ({ width: 800, height: 600 }), })); -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('@/sync/domains/state/storage', () => ({ useLocalSetting: () => false, useSetting: () => false, diff --git a/apps/ui/sources/components/tools/shell/views/ToolFullView.taskTranscript.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolFullView.taskTranscript.test.tsx index 1e3c10b86..30c694e61 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolFullView.taskTranscript.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolFullView.taskTranscript.test.tsx @@ -14,10 +14,6 @@ vi.mock('react-native-device-info', () => ({ getDeviceType: () => 'Handset', })); -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, -})); - vi.mock('@/sync/domains/state/storage', () => ({ useLocalSetting: () => false, useSetting: () => false, diff --git a/apps/ui/sources/components/tools/shell/views/ToolFullView.tsx b/apps/ui/sources/components/tools/shell/views/ToolFullView.tsx index 1b4ab5f71..7128ad92c 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolFullView.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolFullView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Text, View, ScrollView, Platform, useWindowDimensions } from 'react-native'; +import { View, ScrollView, Platform, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { ToolCall, Message } from '@/sync/domains/messages/messageTypes'; import { CodeView } from '@/components/ui/media/CodeView'; @@ -16,6 +16,9 @@ import { knownTools } from '@/components/tools/catalog'; import { PermissionFooter } from '../permissions/PermissionFooter'; import { useSetting } from '@/sync/domains/state/storage'; import { MessageView } from '@/components/sessions/transcript/MessageView'; +import { useUnistyles } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + const KNOWN_TOOL_KEYS = Object.keys(knownTools); @@ -32,6 +35,7 @@ interface ToolFullViewProps { } export function ToolFullView({ tool, sessionId, metadata, messages = [], interaction }: ToolFullViewProps) { + const { theme } = useUnistyles(); const toolForRendering = React.useMemo<ToolCall>(() => normalizeToolCallForRendering(tool), [tool]); const normalizedToolName = React.useMemo(() => { @@ -96,7 +100,7 @@ export function ToolFullView({ tool, sessionId, metadata, messages = [], interac {toolForRendering.description && ( <View style={styles.section}> <View style={styles.sectionHeader}> - <Ionicons name="information-circle" size={20} color="#5856D6" /> + <Ionicons name="information-circle" size={20} color={theme.colors.accent.indigo} /> <Text style={styles.sectionTitle}>{t('tools.fullView.description')}</Text> </View> <Text style={styles.description}>{toolForRendering.description}</Text> @@ -106,7 +110,7 @@ export function ToolFullView({ tool, sessionId, metadata, messages = [], interac {toolForRendering.input && ( <View style={styles.section}> <View style={styles.sectionHeader}> - <Ionicons name="log-in" size={20} color="#5856D6" /> + <Ionicons name="log-in" size={20} color={theme.colors.accent.indigo} /> <Text style={styles.sectionTitle}>{t('tools.fullView.inputParams')}</Text> </View> <CodeView code={JSON.stringify(toolForRendering.input, null, 2)} /> @@ -117,7 +121,7 @@ export function ToolFullView({ tool, sessionId, metadata, messages = [], interac {toolForRendering.state === 'completed' && toolForRendering.result && ( <View style={styles.section}> <View style={styles.sectionHeader}> - <Ionicons name="log-out" size={20} color="#34C759" /> + <Ionicons name="log-out" size={20} color={theme.colors.success} /> <Text style={styles.sectionTitle}>{t('tools.fullView.output')}</Text> </View> <CodeView @@ -129,7 +133,7 @@ export function ToolFullView({ tool, sessionId, metadata, messages = [], interac {toolForRendering.state === 'running' && toolForRendering.result && ( <View style={styles.section}> <View style={styles.sectionHeader}> - <Ionicons name="log-out" size={20} color="#34C759" /> + <Ionicons name="log-out" size={20} color={theme.colors.success} /> <Text style={styles.sectionTitle}>{t('tools.fullView.output')}</Text> </View> <StructuredResultView tool={toolForRendering} metadata={metadata || null} messages={messages} sessionId={sessionId} /> @@ -140,7 +144,7 @@ export function ToolFullView({ tool, sessionId, metadata, messages = [], interac {toolForRendering.state === 'error' && toolForRendering.result && ( <View style={styles.section}> <View style={styles.sectionHeader}> - <Ionicons name="close-circle" size={20} color="#FF3B30" /> + <Ionicons name="close-circle" size={20} color={theme.colors.warningCritical} /> <Text style={styles.sectionTitle}>{t('tools.fullView.error')}</Text> </View> <View style={styles.errorContainer}> @@ -157,7 +161,7 @@ export function ToolFullView({ tool, sessionId, metadata, messages = [], interac {toolForRendering.state === 'completed' && !toolForRendering.result && ( <View style={styles.section}> <View style={styles.emptyOutputContainer}> - <Ionicons name="checkmark-circle-outline" size={48} color="#34C759" /> + <Ionicons name="checkmark-circle-outline" size={48} color={theme.colors.success} /> <Text style={styles.emptyOutputText}>{t('tools.fullView.completed')}</Text> <Text style={styles.emptyOutputSubtext}>{t('tools.fullView.noOutput')}</Text> </View> @@ -183,7 +187,7 @@ export function ToolFullView({ tool, sessionId, metadata, messages = [], interac {/* Debug/raw payloads (opt-in) */} <View style={styles.section}> <View style={styles.sectionHeader}> - <Ionicons name="code-slash" size={20} color="#FF9500" /> + <Ionicons name="code-slash" size={20} color={theme.colors.accent.orange} /> <Text style={styles.sectionTitle}>{t('tools.fullView.debug')}</Text> <Text style={[styles.toolId, { marginLeft: 8 }]} diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.acpKindFallback.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.acpKindFallback.test.tsx index edd058bcd..7d25247df 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.acpKindFallback.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.acpKindFallback.test.tsx @@ -9,13 +9,10 @@ vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', - Platform: { OS: 'ios', select: (v: any) => v.ios }, -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { ...rn, Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios } }; +}); vi.mock('react-native-unistyles', () => ({ StyleSheet: { create: (styles: any) => styles }, diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.descriptionFallback.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.descriptionFallback.test.tsx index e0a0607c6..b5f60f27c 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.descriptionFallback.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.descriptionFallback.test.tsx @@ -9,13 +9,10 @@ vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', - Platform: { OS: 'ios', select: (v: any) => v.ios }, -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { ...rn, AppState: rn.AppState, Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios } }; +}); vi.mock('react-native-unistyles', () => ({ StyleSheet: { create: (styles: any) => styles }, @@ -27,6 +24,7 @@ vi.mock('react-native-unistyles', () => ({ text: '#000', textSecondary: '#666', warning: '#f90', + shadow: { color: '#000', opacity: 0.1 }, }, }, }), @@ -123,4 +121,3 @@ describe('ToolView (description fallback)', () => { expect(flattened.join(' ')).toContain('Search for foo'); }); }); - diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.detailLevelFull.singleRenderer.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.detailLevelFull.singleRenderer.test.tsx index 430df8c99..25d773005 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.detailLevelFull.singleRenderer.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.detailLevelFull.singleRenderer.test.tsx @@ -24,6 +24,7 @@ vi.mock('expo-router', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: [], resolveAgentIdFromFlavor: () => null, getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), })); diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.detailLevelTitle.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.detailLevelTitle.test.tsx index 424d64c2a..e07fda157 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.detailLevelTitle.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.detailLevelTitle.test.tsx @@ -24,6 +24,7 @@ vi.mock('expo-router', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: [], resolveAgentIdFromFlavor: () => null, getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), })); diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.diffHeaderActions.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.diffHeaderActions.test.tsx index bb3fcee3e..968b90ba3 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.diffHeaderActions.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.diffHeaderActions.test.tsx @@ -51,6 +51,7 @@ vi.mock('expo-router', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: [], resolveAgentIdFromFlavor: () => null, getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), })); diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.errorObjectResult.test.ts b/apps/ui/sources/components/tools/shell/views/ToolView.errorObjectResult.test.ts index 4a4a96f7d..eca22d17b 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.errorObjectResult.test.ts +++ b/apps/ui/sources/components/tools/shell/views/ToolView.errorObjectResult.test.ts @@ -13,14 +13,14 @@ vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', - NativeModules: {}, - Platform: { OS: 'ios', select: (v: any) => v.ios }, -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + NativeModules: {}, + Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios }, + }; +}); vi.mock('react-native-unistyles', () => ({ StyleSheet: { create: (styles: any) => styles }, diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.exitPlanMode.test.ts b/apps/ui/sources/components/tools/shell/views/ToolView.exitPlanMode.test.ts index c384a0c71..ada7f339c 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.exitPlanMode.test.ts +++ b/apps/ui/sources/components/tools/shell/views/ToolView.exitPlanMode.test.ts @@ -9,14 +9,15 @@ vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', - NativeModules: {}, - Platform: { OS: 'ios', select: (v: any) => v.ios }, -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + AppState: rn.AppState, + NativeModules: {}, + Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios }, + }; +}); vi.mock('react-native-unistyles', () => ({ StyleSheet: { create: (styles: any) => styles }, @@ -28,6 +29,7 @@ vi.mock('react-native-unistyles', () => ({ text: '#000', textSecondary: '#666', warning: '#f00', + shadow: { color: '#000', opacity: 0.1 }, }, }, }), diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.fixtures.v1.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.fixtures.v1.test.tsx index a8f3e1958..a29abb0dd 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.fixtures.v1.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.fixtures.v1.test.tsx @@ -25,6 +25,7 @@ vi.mock('expo-router', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: [], resolveAgentIdFromFlavor: () => null, getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), })); diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.minimalSpecificView.test.ts b/apps/ui/sources/components/tools/shell/views/ToolView.minimalSpecificView.test.ts index 0104f8d16..978d1fa54 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.minimalSpecificView.test.ts +++ b/apps/ui/sources/components/tools/shell/views/ToolView.minimalSpecificView.test.ts @@ -9,14 +9,14 @@ vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', - NativeModules: {}, - Platform: { OS: 'ios', select: (v: any) => v.ios }, -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + NativeModules: {}, + Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios }, + }; +}); vi.mock('react-native-unistyles', () => ({ StyleSheet: { create: (styles: any) => styles }, diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.minimalStructuredFallback.test.ts b/apps/ui/sources/components/tools/shell/views/ToolView.minimalStructuredFallback.test.ts index eed16cc55..faf4ae974 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.minimalStructuredFallback.test.ts +++ b/apps/ui/sources/components/tools/shell/views/ToolView.minimalStructuredFallback.test.ts @@ -15,6 +15,7 @@ vi.mock('react-native', () => ({ TouchableOpacity: 'TouchableOpacity', ActivityIndicator: 'ActivityIndicator', NativeModules: {}, + AppState: { addEventListener: () => ({ remove: () => {} }) }, Platform: { OS: 'ios', select: (v: any) => v.ios }, })); diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.permissionDenied.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.permissionDenied.test.tsx index e0308d3a7..575fd9d4d 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.permissionDenied.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.permissionDenied.test.tsx @@ -10,13 +10,10 @@ vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', - Platform: { OS: 'ios', select: (v: any) => v.ios }, -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { ...rn, AppState: rn.AppState, Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios } }; +}); vi.mock('react-native-unistyles', () => ({ StyleSheet: { create: (styles: any) => styles }, @@ -28,6 +25,7 @@ vi.mock('react-native-unistyles', () => ({ text: '#000', textSecondary: '#666', warning: '#f90', + shadow: { color: '#000', opacity: 0.1 }, }, }, }), diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.permissionPending.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.permissionPending.test.tsx index 3f1cc3c52..9742e32e1 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.permissionPending.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.permissionPending.test.tsx @@ -9,13 +9,10 @@ vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', - Platform: { OS: 'ios', select: (v: any) => v.ios }, -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { ...rn, Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios } }; +}); vi.mock('react-native-unistyles', () => ({ StyleSheet: { create: (styles: any) => styles }, diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.runningStructuredFallback.test.ts b/apps/ui/sources/components/tools/shell/views/ToolView.runningStructuredFallback.test.ts index f6078b0d1..f666934c0 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.runningStructuredFallback.test.ts +++ b/apps/ui/sources/components/tools/shell/views/ToolView.runningStructuredFallback.test.ts @@ -9,14 +9,15 @@ vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - TouchableOpacity: 'TouchableOpacity', - ActivityIndicator: 'ActivityIndicator', - NativeModules: {}, - Platform: { OS: 'ios', select: (v: any) => v.ios }, -})); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); + return { + ...rn, + AppState: rn.AppState, + NativeModules: {}, + Platform: { ...rn.Platform, OS: 'ios', select: (v: any) => v.ios }, + }; +}); vi.mock('react-native-unistyles', () => ({ StyleSheet: { create: (styles: any) => styles }, @@ -28,6 +29,7 @@ vi.mock('react-native-unistyles', () => ({ text: '#000', textSecondary: '#666', warning: '#f00', + shadow: { color: '#000', opacity: 0.1 }, }, }, }), diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.tapActionExpand.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.tapActionExpand.test.tsx index 5e70cdb07..19a36305c 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.tapActionExpand.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.tapActionExpand.test.tsx @@ -24,6 +24,7 @@ vi.mock('expo-router', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: [], resolveAgentIdFromFlavor: () => null, getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), })); diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.tsx index a97df6729..fce5267f9 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Text, View, TouchableOpacity, ActivityIndicator, Platform } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, Platform } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons, Octicons } from '@expo/vector-icons'; import { getToolViewComponent } from '@/components/tools/renderers/core/_registry'; @@ -22,6 +22,8 @@ import { normalizeToolCallForRendering } from '@/components/tools/normalization/ import { useSetting } from '@/sync/domains/state/storage'; import { resolveToolViewDetailLevel } from '@/components/tools/normalization/policy/resolveToolViewDetailLevel'; import { ToolHeaderActionsContext } from '../presentation/ToolHeaderActionsContext'; +import { Text } from '@/components/ui/text/Text'; + const KNOWN_TOOL_KEYS = Object.keys(knownTools); diff --git a/apps/ui/sources/components/tools/shell/views/ToolView.unknownToolDefaultTitle.test.tsx b/apps/ui/sources/components/tools/shell/views/ToolView.unknownToolDefaultTitle.test.tsx index 6a430398b..b9ce7c299 100644 --- a/apps/ui/sources/components/tools/shell/views/ToolView.unknownToolDefaultTitle.test.tsx +++ b/apps/ui/sources/components/tools/shell/views/ToolView.unknownToolDefaultTitle.test.tsx @@ -24,6 +24,7 @@ vi.mock('expo-router', () => ({ })); vi.mock('@/agents/catalog/catalog', () => ({ + AGENT_IDS: [], resolveAgentIdFromFlavor: () => null, getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), })); diff --git a/apps/ui/sources/components/ui/buttons/ConnectButton.tsx b/apps/ui/sources/components/ui/buttons/ConnectButton.tsx index d821b59a2..c0c1dd6db 100644 --- a/apps/ui/sources/components/ui/buttons/ConnectButton.tsx +++ b/apps/ui/sources/components/ui/buttons/ConnectButton.tsx @@ -1,12 +1,17 @@ import * as React from 'react'; -import { View, TextInput, Text, TouchableOpacity } from 'react-native'; +import { View, TouchableOpacity } from 'react-native'; import { RoundButton } from './RoundButton'; import { useConnectTerminal } from '@/hooks/session/useConnectTerminal'; import { trackConnectAttempt } from '@/track'; import { Ionicons } from '@expo/vector-icons'; import { t } from '@/text'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export const ConnectButton = React.memo(() => { + const { theme } = useUnistyles(); + const styles = stylesheet; const { connectTerminal, connectWithUrl, isLoading } = useConnectTerminal(); const [manualUrl, setManualUrl] = React.useState(''); const [showManualEntry, setShowManualEntry] = React.useState(false); @@ -25,7 +30,7 @@ export const ConnectButton = React.memo(() => { }; return ( - <View style={{ width: 210 }}> + <View style={styles.container}> <RoundButton title={t('connectButton.authenticate')} size="large" @@ -35,61 +40,31 @@ export const ConnectButton = React.memo(() => { <TouchableOpacity onPress={() => setShowManualEntry(!showManualEntry)} - style={{ - marginTop: 12, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }} + style={styles.manualToggle} > <Ionicons name="link-outline" size={16} - color="#666" - style={{ marginRight: 6 }} + color={theme.colors.textSecondary} + style={styles.manualToggleIcon} /> - <Text style={{ - fontSize: 14, - color: '#666', - textDecorationLine: 'underline', - }}> + <Text style={styles.manualToggleText}> {t('connectButton.authenticateWithUrlPaste')} </Text> </TouchableOpacity> {showManualEntry && ( - <View style={{ - marginTop: 12, - padding: 12, - borderRadius: 8, - backgroundColor: '#f5f5f5', - width: 210, - }}> - <Text style={{ - fontSize: 12, - color: '#666', - marginBottom: 8, - }}> + <View style={styles.manualEntryContainer}> + <Text style={styles.manualEntryLabel}> {t('connectButton.pasteAuthUrl')} </Text> - <View style={{ - flexDirection: 'row', - alignItems: 'center', - }}> + <View style={styles.manualEntryRow}> <TextInput - style={{ - flex: 1, - backgroundColor: 'white', - borderWidth: 1, - borderColor: '#ddd', - borderRadius: 6, - padding: 8, - fontSize: 12, - }} + style={styles.manualUrlInput} value={manualUrl} onChangeText={setManualUrl} placeholder={t('connect.terminalUrlPlaceholder')} - placeholderTextColor="#999" + placeholderTextColor={theme.colors.input.placeholder} autoCapitalize="none" autoCorrect={false} onSubmitEditing={handleManualConnect} @@ -97,16 +72,15 @@ export const ConnectButton = React.memo(() => { <TouchableOpacity onPress={handleManualConnect} disabled={!manualUrl.trim()} - style={{ - marginLeft: 8, - padding: 8, - opacity: manualUrl.trim() ? 1 : 0.5, - }} + style={[ + styles.manualSubmitButton, + manualUrl.trim() ? null : styles.manualSubmitButtonDisabled, + ]} > <Ionicons name="checkmark-circle" size={24} - color="#007AFF" + color={theme.colors.accent.blue} /> </TouchableOpacity> </View> @@ -115,3 +89,56 @@ export const ConnectButton = React.memo(() => { </View> ) }); + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: 210, + }, + manualToggle: { + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + manualToggleIcon: { + marginRight: 6, + }, + manualToggleText: { + fontSize: 14, + color: theme.colors.textSecondary, + textDecorationLine: 'underline', + }, + manualEntryContainer: { + marginTop: 12, + padding: 12, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + width: 210, + }, + manualEntryLabel: { + fontSize: 12, + color: theme.colors.textSecondary, + marginBottom: 8, + }, + manualEntryRow: { + flexDirection: 'row', + alignItems: 'center', + }, + manualUrlInput: { + flex: 1, + backgroundColor: theme.colors.input.background, + borderWidth: 1, + borderColor: theme.colors.divider, + borderRadius: 6, + padding: 8, + fontSize: 12, + color: theme.colors.input.text, + }, + manualSubmitButton: { + marginLeft: 8, + padding: 8, + }, + manualSubmitButtonDisabled: { + opacity: 0.5, + }, +})); diff --git a/apps/ui/sources/components/ui/buttons/FABWide.tsx b/apps/ui/sources/components/ui/buttons/FABWide.tsx index 4dc885655..154183b86 100644 --- a/apps/ui/sources/components/ui/buttons/FABWide.tsx +++ b/apps/ui/sources/components/ui/buttons/FABWide.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { View, Pressable, Text } from 'react-native'; +import { View, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme, runtime) => ({ container: { diff --git a/apps/ui/sources/components/ui/buttons/PlusPlus.tsx b/apps/ui/sources/components/ui/buttons/PlusPlus.tsx index 910fe6fad..1e6ab663b 100644 --- a/apps/ui/sources/components/ui/buttons/PlusPlus.tsx +++ b/apps/ui/sources/components/ui/buttons/PlusPlus.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { Text, ViewStyle } from 'react-native'; +import { ViewStyle } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import MaskedView from '@react-native-masked-view/masked-view'; import { Typography } from '@/constants/Typography'; +import { Text } from '@/components/ui/text/Text'; + interface PlusPlusProps { fontSize: number; diff --git a/apps/ui/sources/components/ui/buttons/PlusPlus.web.tsx b/apps/ui/sources/components/ui/buttons/PlusPlus.web.tsx index ff58a8e84..d46bcec41 100644 --- a/apps/ui/sources/components/ui/buttons/PlusPlus.web.tsx +++ b/apps/ui/sources/components/ui/buttons/PlusPlus.web.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { Text, View, ViewStyle } from 'react-native'; +import { View, ViewStyle } from 'react-native'; import { Typography } from '@/constants/Typography'; +import { Text } from '@/components/ui/text/Text'; + interface PlusPlusProps { fontSize: number; diff --git a/apps/ui/sources/components/ui/buttons/PrimaryCircleIconButton.test.tsx b/apps/ui/sources/components/ui/buttons/PrimaryCircleIconButton.test.tsx new file mode 100644 index 000000000..e818d6684 --- /dev/null +++ b/apps/ui/sources/components/ui/buttons/PrimaryCircleIconButton.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', async (importOriginal) => { + const actual = await importOriginal<any>(); + return { + ...actual, + View: 'View', + ActivityIndicator: 'ActivityIndicator', + Pressable: ({ children, ...props }: any) => React.createElement('Pressable', props, children), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + button: { primary: { background: '#000', tint: '#fff', disabled: '#666' } }, + surfaceHigh: '#111', + surface: '#111', + divider: '#222', + text: '#fff', + }, + }, + }), + StyleSheet: { + create: (factory: any) => factory({ + colors: { + button: { primary: { background: '#000', tint: '#fff', disabled: '#666' } }, + surfaceHigh: '#111', + surface: '#111', + divider: '#222', + text: '#fff', + }, + }), + }, +})); + +describe('PrimaryCircleIconButton', () => { + it('forwards testID to the Pressable', async () => { + const { PrimaryCircleIconButton } = await import('./PrimaryCircleIconButton'); + let tree!: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create( + <PrimaryCircleIconButton + testID="circle-button" + active + accessibilityLabel="Send" + onPress={() => {}} + > + <span /> + </PrimaryCircleIconButton>, + ); + }); + const pressable = tree.root.findByType('Pressable' as any); + expect(pressable.props.testID).toBe('circle-button'); + }); +}); diff --git a/apps/ui/sources/components/ui/buttons/PrimaryCircleIconButton.tsx b/apps/ui/sources/components/ui/buttons/PrimaryCircleIconButton.tsx index fe753dca1..af7743095 100644 --- a/apps/ui/sources/components/ui/buttons/PrimaryCircleIconButton.tsx +++ b/apps/ui/sources/components/ui/buttons/PrimaryCircleIconButton.tsx @@ -26,6 +26,7 @@ export const PrimaryCircleIconButton = React.memo( active: boolean; disabled?: boolean; loading?: boolean; + testID?: string; accessibilityLabel: string; accessibilityHint?: string; accessibilityState?: { disabled?: boolean } & Record<string, unknown>; @@ -48,6 +49,7 @@ export const PrimaryCircleIconButton = React.memo( return ( <View style={[styles.root, props.style]}> <Pressable + testID={props.testID} accessibilityRole="button" accessibilityLabel={props.accessibilityLabel} accessibilityHint={props.accessibilityHint} diff --git a/apps/ui/sources/components/ui/buttons/RoundButton.test.tsx b/apps/ui/sources/components/ui/buttons/RoundButton.test.tsx new file mode 100644 index 000000000..a6ee28cdf --- /dev/null +++ b/apps/ui/sources/components/ui/buttons/RoundButton.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', async (importOriginal) => { + const actual = await importOriginal<any>(); + return { + ...actual, + Platform: { ...(actual.Platform ?? {}), OS: 'web' }, + View: 'View', + Text: 'Text', + ActivityIndicator: 'ActivityIndicator', + Pressable: ({ children, ...props }: any) => React.createElement('Pressable', props, children), + }; +}); + +vi.mock('react-native-unistyles', () => { + const theme = { + colors: { + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, + button: { primary: { background: '#000', tint: '#fff' } }, + text: '#111', + }, + }; + return { + useUnistyles: () => ({ theme }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input(theme) : input) }, + }; +}); + +describe('RoundButton', () => { + it('forwards testID to the Pressable', async () => { + const { RoundButton } = await import('./RoundButton'); + let tree!: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create(<RoundButton title="Hello" testID="round-button" />); + }); + const pressable = tree.root.findByType('Pressable' as any); + expect(pressable.props.testID).toBe('round-button'); + }); +}); diff --git a/apps/ui/sources/components/ui/buttons/RoundButton.tsx b/apps/ui/sources/components/ui/buttons/RoundButton.tsx index ddc196d14..25a5c2881 100644 --- a/apps/ui/sources/components/ui/buttons/RoundButton.tsx +++ b/apps/ui/sources/components/ui/buttons/RoundButton.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { ActivityIndicator, Platform, Pressable, StyleProp, Text, TextStyle, View, ViewStyle } from 'react-native'; +import { ActivityIndicator, Platform, Pressable, StyleProp, TextStyle, View, ViewStyle } from 'react-native'; import { iOSUIKit } from 'react-native-typography'; import { Typography } from '@/constants/Typography'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + export type RoundButtonSize = 'large' | 'normal' | 'small'; const sizes: { [key in RoundButtonSize]: { fontSize: number, hitSlop: number, pad: number } } = { @@ -37,7 +39,19 @@ const stylesheet = StyleSheet.create((theme) => ({ }, })); -export const RoundButton = React.memo((props: { size?: RoundButtonSize, display?: RoundButtonDisplay, title?: any, style?: StyleProp<ViewStyle>, textStyle?: StyleProp<TextStyle>, disabled?: boolean, loading?: boolean, onPress?: () => void, action?: () => Promise<any> }) => { +export const RoundButton = React.memo((props: { + size?: RoundButtonSize, + display?: RoundButtonDisplay, + title?: any, + style?: StyleProp<ViewStyle>, + textStyle?: StyleProp<TextStyle>, + disabled?: boolean, + loading?: boolean, + testID?: string, + accessibilityLabel?: string, + onPress?: () => void, + action?: () => Promise<any> +}) => { const { theme } = useUnistyles(); const styles = stylesheet; const [loading, setLoading] = React.useState(false); @@ -80,6 +94,9 @@ export const RoundButton = React.memo((props: { size?: RoundButtonSize, display? return ( <Pressable + testID={props.testID} + accessibilityRole="button" + accessibilityLabel={props.accessibilityLabel} disabled={doLoading || props.disabled} hitSlop={size.hitSlop} style={(p) => ([ @@ -126,4 +143,4 @@ export const RoundButton = React.memo((props: { size?: RoundButtonSize, display? </View> </Pressable> ) -}); \ No newline at end of file +}); diff --git a/apps/ui/sources/components/ui/code/editor/bridge/codemirrorWebViewHtml.test.ts b/apps/ui/sources/components/ui/code/editor/bridge/codemirrorWebViewHtml.test.ts new file mode 100644 index 000000000..806b5045a --- /dev/null +++ b/apps/ui/sources/components/ui/code/editor/bridge/codemirrorWebViewHtml.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCodeMirrorWebViewHtml } from './codemirrorWebViewHtml'; + +describe('buildCodeMirrorWebViewHtml', () => { + it('scales editor font metrics via uiFontScale', () => { + const html = buildCodeMirrorWebViewHtml({ + theme: { + backgroundColor: '#000', + textColor: '#fff', + dividerColor: '#333', + isDark: true, + }, + wrapLines: true, + showLineNumbers: true, + changeDebounceMs: 100, + maxChunkBytes: 64_000, + uiFontScale: 2, + } as any); + + expect(html).toContain('font-size: 26px;'); + expect(html).toContain('line-height: 40px;'); + }); +}); + diff --git a/apps/ui/sources/components/ui/code/editor/bridge/codemirrorWebViewHtml.ts b/apps/ui/sources/components/ui/code/editor/bridge/codemirrorWebViewHtml.ts index a5ebcc891..783f11f6b 100644 --- a/apps/ui/sources/components/ui/code/editor/bridge/codemirrorWebViewHtml.ts +++ b/apps/ui/sources/components/ui/code/editor/bridge/codemirrorWebViewHtml.ts @@ -1,3 +1,5 @@ +import { resolveCodeEditorFontMetrics } from '../codeEditorFontMetrics'; + export type CodeMirrorWebViewTheme = Readonly<{ backgroundColor: string; textColor: string; @@ -11,12 +13,20 @@ export function buildCodeMirrorWebViewHtml(params: Readonly<{ showLineNumbers: boolean; changeDebounceMs: number; maxChunkBytes: number; + uiFontScale?: number; + osFontScale?: number; }>): string { const themeJson = JSON.stringify(params.theme); const wrapLines = params.wrapLines ? 'true' : 'false'; const showLineNumbers = params.showLineNumbers ? 'true' : 'false'; const changeDebounceMs = Math.max(0, Math.floor(params.changeDebounceMs)); const maxChunkBytes = Math.max(8_000, Math.floor(params.maxChunkBytes)); + const fontMetrics = resolveCodeEditorFontMetrics({ + uiFontScale: typeof params.uiFontScale === 'number' && Number.isFinite(params.uiFontScale) ? params.uiFontScale : 1, + osFontScale: typeof params.osFontScale === 'number' && Number.isFinite(params.osFontScale) ? params.osFontScale : 1, + }); + const fontSizePx = fontMetrics.fontSize; + const lineHeightPx = fontMetrics.lineHeight; // Notes: // - We load CodeMirror 6 via CDN ESM modules (experimental; best-effort). @@ -38,8 +48,8 @@ export function buildCodeMirrorWebViewHtml(params: Readonly<{ .cm-editor { height: 100%; font-family: Menlo, ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 13px; - line-height: 20px; + font-size: ${fontSizePx}px; + line-height: ${lineHeightPx}px; } .cm-gutters { border-right: 1px solid ${params.theme.dividerColor}; @@ -347,4 +357,3 @@ export function buildCodeMirrorWebViewHtml(params: Readonly<{ </body> </html>`; } - diff --git a/apps/ui/sources/components/ui/code/editor/codeEditorFontMetrics.test.ts b/apps/ui/sources/components/ui/code/editor/codeEditorFontMetrics.test.ts new file mode 100644 index 000000000..9c644875c --- /dev/null +++ b/apps/ui/sources/components/ui/code/editor/codeEditorFontMetrics.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveCodeEditorFontMetrics } from './codeEditorFontMetrics'; + +describe('resolveCodeEditorFontMetrics', () => { + it('applies uiFontScale and osFontScale', () => { + const m = resolveCodeEditorFontMetrics({ uiFontScale: 2, osFontScale: 1.25 }); + expect(m.fontSize).toBe(33); + expect(m.lineHeight).toBe(50); + expect(m.scale).toBeCloseTo(2.5, 5); + }); +}); + diff --git a/apps/ui/sources/components/ui/code/editor/codeEditorFontMetrics.ts b/apps/ui/sources/components/ui/code/editor/codeEditorFontMetrics.ts new file mode 100644 index 000000000..8fa398cc7 --- /dev/null +++ b/apps/ui/sources/components/ui/code/editor/codeEditorFontMetrics.ts @@ -0,0 +1,34 @@ +export const CODE_EDITOR_BASE_FONT_SIZE = 13; +export const CODE_EDITOR_BASE_LINE_HEIGHT = 20; + +export type CodeEditorFontMetrics = Readonly<{ + scale: number; + fontSize: number; + lineHeight: number; +}>; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function resolveCodeEditorFontMetrics(params: Readonly<{ + uiFontScale: number; + osFontScale?: number; +}>): CodeEditorFontMetrics { + const uiFontScale = + typeof params.uiFontScale === 'number' && Number.isFinite(params.uiFontScale) + ? clamp(params.uiFontScale, 0.5, 2.5) + : 1; + const osFontScale = + typeof params.osFontScale === 'number' && Number.isFinite(params.osFontScale) + ? Math.max(0.5, params.osFontScale) + : 1; + + const scale = uiFontScale * osFontScale; + return { + scale, + fontSize: Math.max(8, Math.round(CODE_EDITOR_BASE_FONT_SIZE * scale)), + lineHeight: Math.max(10, Math.round(CODE_EDITOR_BASE_LINE_HEIGHT * scale)), + }; +} + diff --git a/apps/ui/sources/components/ui/code/editor/surfaces/CodeMirrorWebViewSurface.native.tsx b/apps/ui/sources/components/ui/code/editor/surfaces/CodeMirrorWebViewSurface.native.tsx index c1779114a..6b57f855b 100644 --- a/apps/ui/sources/components/ui/code/editor/surfaces/CodeMirrorWebViewSurface.native.tsx +++ b/apps/ui/sources/components/ui/code/editor/surfaces/CodeMirrorWebViewSurface.native.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { View } from 'react-native'; +import { PixelRatio, View } from 'react-native'; import { WebView } from 'react-native-webview'; import { useUnistyles } from 'react-native-unistyles'; +import { useLocalSetting } from '@/sync/store/hooks'; import type { CodeEditorProps } from '../codeEditorTypes'; import { encodeChunkedEnvelope, decodeChunkedEnvelope } from '../bridge/chunkedBridge'; import { buildCodeMirrorWebViewHtml } from '../bridge/codemirrorWebViewHtml'; @@ -13,6 +14,7 @@ function createMessageId(): string { export function CodeMirrorWebViewSurface(props: CodeEditorProps) { const { theme } = useUnistyles(); + const uiFontScale = useLocalSetting('uiFontScale'); const webViewRef = React.useRef<WebView>(null); const readyRef = React.useRef(false); const pendingInitRef = React.useRef<null | { doc: string }>(null); @@ -36,11 +38,14 @@ export function CodeMirrorWebViewSurface(props: CodeEditorProps) { showLineNumbers, changeDebounceMs, maxChunkBytes, + uiFontScale, + osFontScale: typeof PixelRatio.getFontScale === 'function' ? PixelRatio.getFontScale() : 1, }), [ changeDebounceMs, maxChunkBytes, showLineNumbers, + uiFontScale, theme.colors.divider, theme.colors.surfaceHighest, theme.colors.text, @@ -116,4 +121,3 @@ export function CodeMirrorWebViewSurface(props: CodeEditorProps) { </View> ); } - diff --git a/apps/ui/sources/components/ui/code/editor/surfaces/MonacoEditorSurface.web.test.tsx b/apps/ui/sources/components/ui/code/editor/surfaces/MonacoEditorSurface.web.test.tsx index c289aa2e4..6d742d98c 100644 --- a/apps/ui/sources/components/ui/code/editor/surfaces/MonacoEditorSurface.web.test.tsx +++ b/apps/ui/sources/components/ui/code/editor/surfaces/MonacoEditorSurface.web.test.tsx @@ -7,6 +7,11 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('react-native', () => ({ View: ({ children, ...props }: any) => React.createElement('View', props, children), TextInput: ({ children, ...props }: any) => React.createElement('TextInput', props, children), + Platform: { + OS: 'web', + select: (spec: Record<string, unknown>) => + spec && Object.prototype.hasOwnProperty.call(spec, 'web') ? (spec as any).web : (spec as any).default, + }, })); vi.mock('react-native-unistyles', () => ({ @@ -22,6 +27,13 @@ vi.mock('react-native-unistyles', () => ({ }), })); +vi.mock('@/sync/store/hooks', () => ({ + useLocalSetting: (key: string) => { + if (key === 'uiFontScale') return 2; + return null; + }, +})); + describe('MonacoEditorSurface (web)', () => { it('renders a fallback TextInput when Monaco is unavailable', async () => { const { MonacoEditorSurface } = await import('./MonacoEditorSurface.web'); @@ -41,6 +53,9 @@ describe('MonacoEditorSurface (web)', () => { const inputs = tree.root.findAllByType('TextInput' as any); expect(inputs).toHaveLength(1); expect(inputs[0]!.props.value).toBe('hello'); + + const style = inputs[0]!.props.style; + const flattened = Array.isArray(style) ? Object.assign({}, ...style.filter(Boolean)) : style; + expect(flattened.fontSize).toBe(26); }); }); - diff --git a/apps/ui/sources/components/ui/code/editor/surfaces/MonacoEditorSurface.web.tsx b/apps/ui/sources/components/ui/code/editor/surfaces/MonacoEditorSurface.web.tsx index 77e7da50b..c543b2f09 100644 --- a/apps/ui/sources/components/ui/code/editor/surfaces/MonacoEditorSurface.web.tsx +++ b/apps/ui/sources/components/ui/code/editor/surfaces/MonacoEditorSurface.web.tsx @@ -1,9 +1,13 @@ import React from 'react'; -import { TextInput, View } from 'react-native'; +import { View } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import type { CodeEditorProps } from '../codeEditorTypes'; import { resolveMonacoLanguageId } from '../codeEditorTypes'; +import { TextInput } from '@/components/ui/text/Text'; +import { useLocalSetting } from '@/sync/store/hooks'; +import { resolveCodeEditorFontMetrics } from '../codeEditorFontMetrics'; + type MonacoType = any; @@ -88,6 +92,11 @@ async function ensureMonaco(): Promise<MonacoType> { export function MonacoEditorSurface(props: CodeEditorProps) { const { theme } = useUnistyles(); + const uiFontScale = useLocalSetting('uiFontScale'); + const fontMetrics = React.useMemo( + () => resolveCodeEditorFontMetrics({ uiFontScale }), + [uiFontScale], + ); const containerRef = React.useRef<any>(null); const editorRef = React.useRef<any>(null); const modelRef = React.useRef<any>(null); @@ -119,7 +128,8 @@ export function MonacoEditorSurface(props: CodeEditorProps) { scrollBeyondLastLine: false, wordWrap: wrapLines ? 'on' : 'off', lineNumbers: showLineNumbers ? 'on' : 'off', - fontSize: 13, + fontSize: fontMetrics.fontSize, + lineHeight: fontMetrics.lineHeight, fontFamily: 'Menlo, ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', tabSize: 2, @@ -158,6 +168,19 @@ export function MonacoEditorSurface(props: CodeEditorProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.resetKey]); + React.useEffect(() => { + const editor = editorRef.current; + if (!editor?.updateOptions) return; + try { + editor.updateOptions({ + fontSize: fontMetrics.fontSize, + lineHeight: fontMetrics.lineHeight, + }); + } catch { + // ignore + } + }, [fontMetrics.fontSize, fontMetrics.lineHeight]); + // Keep the Monaco model in sync when props.value changes externally. React.useEffect(() => { const model = modelRef.current; @@ -188,6 +211,7 @@ export function MonacoEditorSurface(props: CodeEditorProps) { onChangeText={props.onChange} editable={!readOnly} multiline + disableUiFontScaling style={{ flex: 1, padding: 10, @@ -195,8 +219,8 @@ export function MonacoEditorSurface(props: CodeEditorProps) { backgroundColor: theme.colors.surfaceHighest, fontFamily: 'Menlo, ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - fontSize: 13, - lineHeight: 20, + fontSize: fontMetrics.fontSize, + lineHeight: fontMetrics.lineHeight, }} /> </View> @@ -210,4 +234,3 @@ export function MonacoEditorSurface(props: CodeEditorProps) { </View> ); } - diff --git a/apps/ui/sources/components/ui/code/view/CodeGutter.tsx b/apps/ui/sources/components/ui/code/view/CodeGutter.tsx index 66bdada7d..85831e624 100644 --- a/apps/ui/sources/components/ui/code/view/CodeGutter.tsx +++ b/apps/ui/sources/components/ui/code/view/CodeGutter.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import type { CodeLine } from '@/components/ui/code/model/codeLineTypes'; import { Typography } from '@/constants/Typography'; +import { Text } from '@/components/ui/text/Text'; + export function CodeGutter(props: { line: CodeLine; showLineNumbers?: boolean }) { const { theme } = useUnistyles(); diff --git a/apps/ui/sources/components/ui/code/view/CodeLineRow.test.tsx b/apps/ui/sources/components/ui/code/view/CodeLineRow.test.tsx index b1c864013..5bedda41f 100644 --- a/apps/ui/sources/components/ui/code/view/CodeLineRow.test.tsx +++ b/apps/ui/sources/components/ui/code/view/CodeLineRow.test.tsx @@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; vi.mock('react-native', () => ({ - Platform: { OS: 'web' }, + Platform: { OS: 'web', select: (options: any) => options?.web ?? options?.default ?? options?.ios ?? options?.android }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, View: ({ children, ...props }: any) => React.createElement('View', props, children), Text: ({ children, ...props }: any) => React.createElement('Text', props, children), Pressable: ({ children, ...props }: any) => React.createElement('Pressable', props, children), @@ -15,37 +16,40 @@ vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ - theme: { - colors: { - textSecondary: '#666', - syntaxKeyword: '#b00', - syntaxString: '#070', - syntaxNumber: '#00b', - syntaxFunction: '#850', - syntaxDefault: '#111', - syntaxComment: '#777', - syntaxBracket1: '#a00', - syntaxBracket2: '#0a0', - syntaxBracket3: '#00a', - syntaxBracket4: '#aa0', - syntaxBracket5: '#0aa', - surfaceHigh: '#eee', - diff: { - addedBg: '#e6ffed', - removedBg: '#ffeef0', - hunkHeaderBg: '#f6f8fa', - addedText: '#22863a', - removedText: '#b31d28', - hunkHeaderText: '#111', - contextText: '#24292e', - }, +vi.mock('react-native-unistyles', () => { + const theme = { + colors: { + textSecondary: '#666', + syntaxKeyword: '#b00', + syntaxString: '#070', + syntaxNumber: '#00b', + syntaxFunction: '#850', + syntaxDefault: '#111', + syntaxComment: '#777', + syntaxBracket1: '#a00', + syntaxBracket2: '#0a0', + syntaxBracket3: '#00a', + syntaxBracket4: '#aa0', + syntaxBracket5: '#0aa', + surfaceHigh: '#eee', + diff: { + addedBg: '#e6ffed', + removedBg: '#ffeef0', + hunkHeaderBg: '#f6f8fa', + addedText: '#22863a', + removedText: '#b31d28', + hunkHeaderText: '#111', + contextText: '#24292e', }, + shadow: { color: '#000', opacity: 0.2 }, }, - }), - StyleSheet: { create: (v: any) => (typeof v === 'function' ? v({ colors: {} }) : v) }, -})); + }; + + return { + useUnistyles: () => ({ theme }), + StyleSheet: { create: (v: any) => (typeof v === 'function' ? v(theme) : v) }, + }; +}); describe('CodeLineRow', () => { it('renders prefix and code segments', async () => { diff --git a/apps/ui/sources/components/ui/code/view/CodeLineRow.tsx b/apps/ui/sources/components/ui/code/view/CodeLineRow.tsx index 224b0bfe2..bfcbacdac 100644 --- a/apps/ui/sources/components/ui/code/view/CodeLineRow.tsx +++ b/apps/ui/sources/components/ui/code/view/CodeLineRow.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pressable, Text, View, Platform } from 'react-native'; +import { Pressable, View, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import type { TextStyle } from 'react-native'; @@ -9,6 +9,8 @@ import { Typography } from '@/constants/Typography'; import { tokenizeSimpleSyntaxLine } from '@/components/ui/code/tokenization/simpleSyntaxTokenizer'; import { CodeGutter } from './CodeGutter'; +import { Text } from '@/components/ui/text/Text'; + export function CodeLineRow(props: { line: CodeLine; diff --git a/apps/ui/sources/components/ui/empty/EmptyMessages.tsx b/apps/ui/sources/components/ui/empty/EmptyMessages.tsx index 2fc826e5f..ce40b8605 100644 --- a/apps/ui/sources/components/ui/empty/EmptyMessages.tsx +++ b/apps/ui/sources/components/ui/empty/EmptyMessages.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { View, Text } from 'react-native'; +import { View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; import { Session } from '@/sync/domains/state/storageTypes'; import { useSessionStatus, formatPathRelativeToHome } from '@/utils/sessions/sessionUtils'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + const stylesheet = StyleSheet.create((theme) => ({ container: { diff --git a/apps/ui/sources/components/ui/feedback/DesktopUpdateBanner.tsx b/apps/ui/sources/components/ui/feedback/DesktopUpdateBanner.tsx index 6d1108b21..3d7baef3a 100644 --- a/apps/ui/sources/components/ui/feedback/DesktopUpdateBanner.tsx +++ b/apps/ui/sources/components/ui/feedback/DesktopUpdateBanner.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useDesktopUpdater } from '@/desktop/updates/useDesktopUpdater'; import { t } from '@/text'; import { getDesktopUpdateBannerModel } from './desktopUpdateBannerModel'; +import { Text } from '@/components/ui/text/Text'; + export function DesktopUpdateBanner() { const styles = stylesheet; diff --git a/apps/ui/sources/components/ui/feedback/ShimmerView.tsx b/apps/ui/sources/components/ui/feedback/ShimmerView.tsx index 2f58083d0..5a58d7c51 100644 --- a/apps/ui/sources/components/ui/feedback/ShimmerView.tsx +++ b/apps/ui/sources/components/ui/feedback/ShimmerView.tsx @@ -12,7 +12,7 @@ import Animated, { } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; import MaskedView from '@react-native-masked-view/masked-view'; -import { StyleSheet } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); @@ -26,14 +26,31 @@ interface ShimmerViewProps { export const ShimmerView = React.memo<ShimmerViewProps>(({ children, - shimmerColors = ['#E0E0E0', '#F0F0F0', '#F8F8F8', '#F0F0F0', '#E0E0E0'], + shimmerColors, shimmerWidthPercent = 80, duration = 1500, style, }) => { + const { theme } = useUnistyles(); const shimmerTranslate = useSharedValue(0); const containerRef = useAnimatedRef<View>(); + const resolvedShimmerColors = React.useMemo<readonly [string, string, ...string[]]>(() => { + if (shimmerColors && shimmerColors.length >= 2) return shimmerColors as readonly [string, string, ...string[]]; + return [ + theme.colors.surfacePressed, + theme.colors.surfaceHigh, + theme.colors.surfaceHighest, + theme.colors.surfaceHigh, + theme.colors.surfacePressed, + ] as const; + }, [ + shimmerColors, + theme.colors.surfaceHigh, + theme.colors.surfaceHighest, + theme.colors.surfacePressed, + ]); + React.useEffect(() => { shimmerTranslate.value = withRepeat( withTiming(1, { @@ -76,11 +93,11 @@ export const ShimmerView = React.memo<ShimmerViewProps>(({ } > {/* Base background */} - <View style={[StyleSheet.absoluteFillObject, styles.background]} /> + <View style={[StyleSheet.absoluteFillObject, { backgroundColor: resolvedShimmerColors[0] }]} /> {/* Animated shimmer */} <AnimatedLinearGradient - colors={shimmerColors} + colors={resolvedShimmerColors} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={[ @@ -98,9 +115,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: 'transparent', }, - background: { - backgroundColor: '#E0E0E0', - }, hiddenChildren: { opacity: 0, }, diff --git a/apps/ui/sources/components/ui/forms/InlineAddExpander.tsx b/apps/ui/sources/components/ui/forms/InlineAddExpander.tsx index 918b62b5f..7d97f28bb 100644 --- a/apps/ui/sources/components/ui/forms/InlineAddExpander.tsx +++ b/apps/ui/sources/components/ui/forms/InlineAddExpander.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import type { StyleProp, ViewStyle } from 'react-native'; import { Item } from '@/components/ui/lists/Item'; import { Typography } from '@/constants/Typography'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export interface InlineAddExpanderProps { isOpen: boolean; @@ -24,7 +26,7 @@ export interface InlineAddExpanderProps { cancelLabel: string; saveLabel: string; - autoFocusRef?: React.RefObject<TextInput | null>; + autoFocusRef?: React.RefObject<React.ElementRef<typeof TextInput> | null>; expandedContainerStyle?: StyleProp<ViewStyle>; } diff --git a/apps/ui/sources/components/ui/forms/MultiTextInput.test.tsx b/apps/ui/sources/components/ui/forms/MultiTextInput.test.tsx new file mode 100644 index 000000000..d62fa9a3a --- /dev/null +++ b/apps/ui/sources/components/ui/forms/MultiTextInput.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', async (importOriginal) => { + const actual = await importOriginal<any>(); + return { + ...actual, + Platform: { ...(actual.Platform ?? {}), OS: 'web' }, + View: 'View', + TextInput: (props: any) => React.createElement('TextInput', props, null), + }; +}); + +vi.mock('react-native-unistyles', () => { + const theme = { + colors: { + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, + input: { text: '#111', placeholder: '#777' }, + }, + }; + return { + useUnistyles: () => ({ theme }), + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input(theme) : input) }, + }; +}); + +vi.mock('react-textarea-autosize', () => ({ + __esModule: true, + default: (props: any) => React.createElement('TextareaAutosize', props, null), +})); + +describe('MultiTextInput', () => { + it('forwards testID to the TextInput', async () => { + const { MultiTextInput } = await import('./MultiTextInput'); + let tree!: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create( + <MultiTextInput + testID="composer-input" + value="" + onChangeText={() => {}} + />, + ); + }); + const input = tree.root.findByType('TextInput' as any); + expect(input.props.testID).toBe('composer-input'); + }); + + it('forwards testID as data-testid on web textarea', async () => { + const { MultiTextInput } = await import('./MultiTextInput.web'); + let tree!: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create( + React.createElement(MultiTextInput as unknown as React.ComponentType<Record<string, unknown>>, { + testID: 'composer-input', + value: '', + onChangeText: () => {}, + }), + ); + }); + const input = tree.root.findByType('TextareaAutosize' as any); + expect(input.props['data-testid']).toBe('composer-input'); + }); +}); diff --git a/apps/ui/sources/components/ui/forms/MultiTextInput.tsx b/apps/ui/sources/components/ui/forms/MultiTextInput.tsx index 8022794fb..e1426af80 100644 --- a/apps/ui/sources/components/ui/forms/MultiTextInput.tsx +++ b/apps/ui/sources/components/ui/forms/MultiTextInput.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; -import { TextInput, Platform, View, NativeSyntheticEvent, TextInputKeyPressEventData, TextInputSelectionChangeEventData } from 'react-native'; +import { Platform, View, NativeSyntheticEvent, TextInputKeyPressEventData, TextInputSelectionChangeEventData } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; +import { TextInput } from '@/components/ui/text/Text'; + export type SupportedKey = 'Enter' | 'Escape' | 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'Tab'; @@ -30,6 +32,7 @@ interface MultiTextInputProps { value: string; onChangeText: (text: string) => void; placeholder?: string; + testID?: string; maxHeight?: number; autoFocus?: boolean; editable?: boolean; @@ -61,7 +64,7 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn const { theme } = useUnistyles(); // Track latest selection in a ref const selectionRef = React.useRef({ start: 0, end: 0 }); - const inputRef = React.useRef<TextInput>(null); + const inputRef = React.useRef<React.ElementRef<typeof TextInput> | null>(null); const handleKeyPress = React.useCallback((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => { if (!onKeyPress) return; @@ -182,6 +185,7 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn <View style={{ width: '100%' }}> <TextInput ref={inputRef} + testID={props.testID} style={{ width: '100%', fontSize: 16, diff --git a/apps/ui/sources/components/ui/forms/MultiTextInput.web.tsx b/apps/ui/sources/components/ui/forms/MultiTextInput.web.tsx index 3674beff2..08729ee6a 100644 --- a/apps/ui/sources/components/ui/forms/MultiTextInput.web.tsx +++ b/apps/ui/sources/components/ui/forms/MultiTextInput.web.tsx @@ -28,6 +28,7 @@ export interface MultiTextInputHandle { } interface MultiTextInputProps { + testID?: string; value: string; onChangeText: (text: string) => void; placeholder?: string; @@ -42,6 +43,7 @@ interface MultiTextInputProps { onStateChange?: (state: TextInputState) => void; onFilesPasted?: (files: readonly File[]) => void; onFilesDropped?: (files: readonly File[]) => void; + onFileDragActiveChange?: (active: boolean) => void; } export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextInputProps>((props, ref) => { @@ -229,6 +231,7 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn <View style={{ width: '100%' }}> <TextareaAutosize ref={textareaRef} + data-testid={props.testID} style={{ width: '100%', padding: '0', diff --git a/apps/ui/sources/components/ui/forms/OptionTiles.tsx b/apps/ui/sources/components/ui/forms/OptionTiles.tsx index b9adf5297..8903af702 100644 --- a/apps/ui/sources/components/ui/forms/OptionTiles.tsx +++ b/apps/ui/sources/components/ui/forms/OptionTiles.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { View, Text, Pressable, Platform } from 'react-native'; +import { View, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; +import { Text } from '@/components/ui/text/Text'; + export interface OptionTile<T extends string> { id: T; diff --git a/apps/ui/sources/components/ui/forms/SearchHeader.tsx b/apps/ui/sources/components/ui/forms/SearchHeader.tsx index 96bf7c81d..10fc7d0a5 100644 --- a/apps/ui/sources/components/ui/forms/SearchHeader.tsx +++ b/apps/ui/sources/components/ui/forms/SearchHeader.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { View, TextInput, Platform, Pressable, StyleProp, ViewStyle } from 'react-native'; +import { View, Platform, Pressable, StyleProp, ViewStyle } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { layout } from '@/components/ui/layout/layout'; import { t } from '@/text'; +import { TextInput } from '@/components/ui/text/Text'; + export interface SearchHeaderProps { value: string; @@ -13,7 +15,7 @@ export interface SearchHeaderProps { containerStyle?: StyleProp<ViewStyle>; autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; autoCorrect?: boolean; - inputRef?: React.Ref<TextInput>; + inputRef?: React.Ref<React.ElementRef<typeof TextInput>>; onFocus?: () => void; onBlur?: () => void; } diff --git a/apps/ui/sources/components/ui/forms/SearchableListSelector.disabledItems.test.tsx b/apps/ui/sources/components/ui/forms/SearchableListSelector.disabledItems.test.tsx index f78785ca6..33c6d56f5 100644 --- a/apps/ui/sources/components/ui/forms/SearchableListSelector.disabledItems.test.tsx +++ b/apps/ui/sources/components/ui/forms/SearchableListSelector.disabledItems.test.tsx @@ -12,6 +12,7 @@ vi.mock('react-native', () => ({ select: (spec: { web?: unknown; ios?: unknown; default?: unknown }) => (spec && 'web' in spec ? spec.web : spec?.default), }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, View: 'View', Text: 'Text', Pressable: 'Pressable', @@ -27,6 +28,9 @@ vi.mock('react-native-unistyles', () => ({ theme: { dark: false, colors: { + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, textSecondary: '#666', textLink: '#00f', button: { primary: { background: '#00f' } }, @@ -117,4 +121,3 @@ describe('SearchableListSelector (disabled items)', () => { expect(onSelect).toHaveBeenCalledWith(items[0]); }); }); - diff --git a/apps/ui/sources/components/ui/forms/SearchableListSelector.tsx b/apps/ui/sources/components/ui/forms/SearchableListSelector.tsx index 4c9b22b75..ec7ef151e 100644 --- a/apps/ui/sources/components/ui/forms/SearchableListSelector.tsx +++ b/apps/ui/sources/components/ui/forms/SearchableListSelector.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, Pressable, Platform } from 'react-native'; +import { View, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -8,6 +8,8 @@ import { Item } from '@/components/ui/lists/Item'; import { t } from '@/text'; import { StatusDot } from '@/components/ui/status/StatusDot'; import { SearchHeader } from '@/components/ui/forms/SearchHeader'; +import { Text } from '@/components/ui/text/Text'; + /** * Configuration object for customizing the SearchableListSelector component. diff --git a/apps/ui/sources/components/ui/forms/dropdown/DropdownMenu.test.ts b/apps/ui/sources/components/ui/forms/dropdown/DropdownMenu.test.ts index c57b1fd6a..890ccd04f 100644 --- a/apps/ui/sources/components/ui/forms/dropdown/DropdownMenu.test.ts +++ b/apps/ui/sources/components/ui/forms/dropdown/DropdownMenu.test.ts @@ -25,12 +25,23 @@ vi.mock('@expo/vector-icons', () => ({ })); vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (factory: any) => factory({ + colors: { + textSecondary: '#666', + divider: '#ddd', + text: '#111', + input: { placeholder: '#999' }, + }, + }, {}) }, useUnistyles: () => ({ theme: { colors: { textSecondary: '#666', divider: '#ddd', text: '#111', + input: { + placeholder: '#999', + }, }, }, }), @@ -89,6 +100,19 @@ vi.mock('@/components/ui/lists/Item', () => ({ }, })); +vi.mock('@/components/ui/text/Text', () => ({ + Text: (props: any) => { + const React = require('react'); + const { Text } = require('react-native'); + return React.createElement(Text, props, props.children); + }, + TextInput: (props: any) => { + const React = require('react'); + const { TextInput } = require('react-native'); + return React.createElement(TextInput, props, props.children); + }, +})); + describe('DropdownMenu', () => { beforeEach(() => { useSelectableMenuSpy.mockReset(); @@ -336,7 +360,8 @@ describe('DropdownMenu', () => { it('uses symmetric content padding and adds bottom padding under results', async () => { const { DropdownMenu } = await import('./DropdownMenu'); - const { Text, TextInput, View } = await import('react-native'); + const { Text, View } = await import('react-native'); + const { TextInput } = await import('@/components/ui/text/Text'); let tree: ReturnType<typeof renderer.create> | undefined; act(() => { @@ -354,7 +379,7 @@ describe('DropdownMenu', () => { const input = tree?.root.findByType(TextInput as any); const inputWrapper = input?.parent; - expect(inputWrapper?.type === 'View' || inputWrapper?.type === View).toBe(true); + expect(inputWrapper?.type === ('View' as any) || inputWrapper?.type === (View as any)).toBe(true); expect(inputWrapper?.props?.style).toMatchObject({ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 }); const paddingNodes = tree?.root.findAll((node: any) => { diff --git a/apps/ui/sources/components/ui/forms/dropdown/DropdownMenu.tsx b/apps/ui/sources/components/ui/forms/dropdown/DropdownMenu.tsx index 3bfaefae8..ca722dedb 100644 --- a/apps/ui/sources/components/ui/forms/dropdown/DropdownMenu.tsx +++ b/apps/ui/sources/components/ui/forms/dropdown/DropdownMenu.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, Text, TextInput, View, type ViewStyle, type StyleProp, type TextStyle } from 'react-native'; +import { Platform, View, ViewStyle, StyleProp, TextStyle } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; @@ -11,6 +11,8 @@ import { SelectableMenuResults } from '@/components/ui/forms/dropdown/Selectable import type { SelectableMenuItem } from '@/components/ui/forms/dropdown/selectableMenuTypes'; import { useSelectableMenu, CREATE_ITEM_ID } from '@/components/ui/forms/dropdown/useSelectableMenu'; import { Item, type ItemProps } from '@/components/ui/lists/Item'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export type DropdownMenuItem = Readonly<{ id: string; @@ -177,8 +179,8 @@ export function DropdownMenu(props: DropdownMenuProps) { ? item.rightElement : item.shortcut ? ( - <View style={{ paddingHorizontal: 10, paddingVertical: 5, backgroundColor: 'rgba(0, 0, 0, 0.04)', borderRadius: 6 }}> - <Text style={{ fontSize: 12, color: '#666', fontWeight: '500' }}> + <View style={{ paddingHorizontal: 10, paddingVertical: 5, backgroundColor: theme.colors.surfacePressedOverlay, borderRadius: 6 }}> + <Text style={{ fontSize: 12, color: theme.colors.textSecondary, fontWeight: '500' }}> {item.shortcut} </Text> </View> @@ -376,7 +378,7 @@ export function DropdownMenu(props: DropdownMenuProps) { value={searchQuery} onChangeText={handleSearchChange} placeholder={props.searchPlaceholder ?? t('commandPalette.placeholder')} - placeholderTextColor="#999" + placeholderTextColor={theme.colors.input.placeholder} autoFocus={false} autoCorrect={false} autoCapitalize="none" diff --git a/apps/ui/sources/components/ui/forms/dropdown/SelectableMenuResults.scrollIntoView.test.ts b/apps/ui/sources/components/ui/forms/dropdown/SelectableMenuResults.scrollIntoView.test.ts index 6084581f1..5c60a17e5 100644 --- a/apps/ui/sources/components/ui/forms/dropdown/SelectableMenuResults.scrollIntoView.test.ts +++ b/apps/ui/sources/components/ui/forms/dropdown/SelectableMenuResults.scrollIntoView.test.ts @@ -9,7 +9,8 @@ const scrollIntoViewSpy = vi.fn(); vi.mock('react-native', () => { const React = require('react'); return { - Platform: { OS: 'web' }, + Platform: { OS: 'web', select: (values: any) => values?.default ?? values?.web ?? values?.ios ?? values?.android }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, Text: (props: any) => React.createElement('Text', props, props.children), View: React.forwardRef((props: any, ref: any) => { React.useImperativeHandle(ref, () => ({ scrollIntoView: scrollIntoViewSpy })); diff --git a/apps/ui/sources/components/ui/forms/dropdown/SelectableMenuResults.tsx b/apps/ui/sources/components/ui/forms/dropdown/SelectableMenuResults.tsx index 79e457dbb..56d7884a1 100644 --- a/apps/ui/sources/components/ui/forms/dropdown/SelectableMenuResults.tsx +++ b/apps/ui/sources/components/ui/forms/dropdown/SelectableMenuResults.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { SelectableRow, type SelectableRowVariant } from '@/components/ui/lists/SelectableRow'; @@ -7,8 +7,10 @@ import { Item } from '@/components/ui/lists/Item'; import { ItemGroupSelectionContext } from '@/components/ui/lists/ItemGroup'; import { ItemGroupRowPositionBoundary } from '@/components/ui/lists/ItemGroupRowPosition'; import type { SelectableMenuCategory, SelectableMenuItem } from './selectableMenuTypes'; +import { Text } from '@/components/ui/text/Text'; -const stylesheet = StyleSheet.create(() => ({ + +const stylesheet = StyleSheet.create((theme) => ({ container: { paddingVertical: 0, }, @@ -18,7 +20,7 @@ const stylesheet = StyleSheet.create(() => ({ }, emptyText: { fontSize: 15, - color: '#999', + color: theme.colors.input.placeholder, letterSpacing: -0.2, ...Typography.default(), }, @@ -27,7 +29,7 @@ const stylesheet = StyleSheet.create(() => ({ paddingTop: 16, paddingBottom: 8, fontSize: 12, - color: '#999', + color: theme.colors.input.placeholder, textTransform: 'uppercase', letterSpacing: 0.8, fontWeight: '600', diff --git a/apps/ui/sources/components/ui/lists/ActionListSection.test.tsx b/apps/ui/sources/components/ui/lists/ActionListSection.test.tsx index 99589a36e..f0a68025a 100644 --- a/apps/ui/sources/components/ui/lists/ActionListSection.test.tsx +++ b/apps/ui/sources/components/ui/lists/ActionListSection.test.tsx @@ -8,13 +8,16 @@ vi.mock('react-native-unistyles', () => { const theme = { dark: false, colors: { + surface: '#fff', + divider: '#ddd', + shadow: { color: '#000', opacity: 0.2 }, text: '#111111', textSecondary: '#666666', }, }; return { - StyleSheet: { create: (factory: any) => factory(theme, {}) }, + StyleSheet: { create: (input: any) => (typeof input === 'function' ? input(theme, {}) : input) }, useUnistyles: () => ({ theme }), }; }); @@ -23,6 +26,7 @@ vi.mock('react-native', () => { const React = require('react'); return { Platform: { OS: 'web', select: (m: any) => m?.web ?? m?.default ?? m?.ios }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, View: (props: any) => React.createElement('View', props, props.children), Text: (props: any) => React.createElement('Text', props, props.children), }; @@ -63,7 +67,7 @@ describe('ActionListSection', () => { // The left icon container is a <View> and must not contain a raw string child on web. expect((selectableRowProps.left.type as any)?.name ?? selectableRowProps.left.type).toBe('View'); expect(typeof selectableRowProps.left.props.children).not.toBe('string'); - expect((selectableRowProps.left.props.children.type as any)?.name ?? selectableRowProps.left.props.children.type).toBe('Text'); + expect(React.isValidElement(selectableRowProps.left.props.children)).toBe(true); expect(selectableRowProps.left.props.children.props.children).toBe('.'); }); }); diff --git a/apps/ui/sources/components/ui/lists/ActionListSection.tsx b/apps/ui/sources/components/ui/lists/ActionListSection.tsx index e3aea3f70..918454e3a 100644 --- a/apps/ui/sources/components/ui/lists/ActionListSection.tsx +++ b/apps/ui/sources/components/ui/lists/ActionListSection.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { SelectableRow } from './SelectableRow'; +import { Text } from '@/components/ui/text/Text'; + export type ActionListItem = Readonly<{ id: string; diff --git a/apps/ui/sources/components/ui/lists/Item.subtitleNormalization.test.tsx b/apps/ui/sources/components/ui/lists/Item.subtitleNormalization.test.tsx index 81053d655..2425f6802 100644 --- a/apps/ui/sources/components/ui/lists/Item.subtitleNormalization.test.tsx +++ b/apps/ui/sources/components/ui/lists/Item.subtitleNormalization.test.tsx @@ -9,6 +9,7 @@ vi.mock('react-native', () => ({ Text: 'Text', Pressable: 'Pressable', ActivityIndicator: 'ActivityIndicator', + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, Platform: { OS: 'web', select: (values: any) => values?.default ?? values?.web ?? values?.ios ?? values?.android, @@ -51,7 +52,10 @@ vi.mock('react-native-unistyles', () => ({ }, }, }), - StyleSheet: { create: (fn: any) => fn({ colors: { groupped: { background: '#111', chevron: '#888' }, divider: '#444' } }, {}) }, + StyleSheet: { + create: (input: any) => + typeof input === 'function' ? input({ colors: { groupped: { background: '#111', chevron: '#888' }, divider: '#444' } }, {}) : input, + }, })); vi.mock('@/constants/Typography', () => ({ diff --git a/apps/ui/sources/components/ui/lists/Item.tsx b/apps/ui/sources/components/ui/lists/Item.tsx index c3aa03fac..3556d1b44 100644 --- a/apps/ui/sources/components/ui/lists/Item.tsx +++ b/apps/ui/sources/components/ui/lists/Item.tsx @@ -1,14 +1,5 @@ import * as React from 'react'; -import { - View, - Text, - Pressable, - StyleProp, - ViewStyle, - TextStyle, - Platform, - ActivityIndicator -} from 'react-native'; +import { View, Pressable, StyleProp, ViewStyle, TextStyle, Platform, ActivityIndicator } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; import * as Clipboard from 'expo-clipboard'; @@ -18,6 +9,8 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ItemGroupSelectionContext } from '@/components/ui/lists/ItemGroup'; import { useItemGroupRowPosition } from '@/components/ui/lists/ItemGroupRowPosition'; import { getItemGroupRowCornerRadii } from '@/components/ui/lists/itemGroupRowCorners'; +import { Text } from '@/components/ui/text/Text'; + export interface ItemProps { testID?: string; diff --git a/apps/ui/sources/components/ui/lists/ItemGroup.tsx b/apps/ui/sources/components/ui/lists/ItemGroup.tsx index 1924a4cf3..86769b3a0 100644 --- a/apps/ui/sources/components/ui/lists/ItemGroup.tsx +++ b/apps/ui/sources/components/ui/lists/ItemGroup.tsx @@ -1,18 +1,13 @@ import * as React from 'react'; -import { - View, - Text, - StyleProp, - ViewStyle, - TextStyle, - Platform -} from 'react-native'; +import { View, StyleProp, ViewStyle, TextStyle, Platform } from 'react-native'; import { Typography } from '@/constants/Typography'; import { layout } from '@/components/ui/layout/layout'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { withItemGroupDividers } from './ItemGroup.dividers'; import { countSelectableItems } from './ItemGroup.selectableCount'; -import { PopoverBoundaryProvider } from '@/components/ui/popover'; +import { PopoverBoundaryProvider, usePopoverBoundaryRef } from '@/components/ui/popover/PopoverBoundary'; +import { Text } from '@/components/ui/text/Text'; + export { withItemGroupDividers } from './ItemGroup.dividers'; @@ -94,6 +89,7 @@ export const ItemGroup = React.memo<ItemGroupProps>((props) => { const { theme } = useUnistyles(); const styles = stylesheet; const popoverBoundaryRef = React.useRef<View>(null); + const inheritedPopoverBoundaryRef = usePopoverBoundaryRef(); const { title, @@ -140,13 +136,21 @@ export const ItemGroup = React.memo<ItemGroupProps>((props) => { {/* Content Container */} <View ref={popoverBoundaryRef} style={[styles.contentContainerOuter, containerStyle]}> - <PopoverBoundaryProvider boundaryRef={popoverBoundaryRef}> + {inheritedPopoverBoundaryRef ? ( <View style={styles.contentContainerInner}> <ItemGroupSelectionContext.Provider value={selectionContextValue}> {withItemGroupDividers(children)} </ItemGroupSelectionContext.Provider> </View> - </PopoverBoundaryProvider> + ) : ( + <PopoverBoundaryProvider boundaryRef={popoverBoundaryRef}> + <View style={styles.contentContainerInner}> + <ItemGroupSelectionContext.Provider value={selectionContextValue}> + {withItemGroupDividers(children)} + </ItemGroupSelectionContext.Provider> + </View> + </PopoverBoundaryProvider> + )} </View> {/* Footer */} diff --git a/apps/ui/sources/components/ui/lists/ItemGroupTitleWithAction.test.ts b/apps/ui/sources/components/ui/lists/ItemGroupTitleWithAction.test.ts index 5a8b253d4..d99540fcb 100644 --- a/apps/ui/sources/components/ui/lists/ItemGroupTitleWithAction.test.ts +++ b/apps/ui/sources/components/ui/lists/ItemGroupTitleWithAction.test.ts @@ -5,12 +5,7 @@ import renderer, { act } from 'react-test-renderer'; // Required for React 18+ act() semantics with react-test-renderer. (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -vi.mock('react-native', () => ({ - View: 'View', - Text: 'Text', - Pressable: 'Pressable', - ActivityIndicator: 'ActivityIndicator', -})); +vi.mock('react-native', async () => await import('@/dev/reactNativeStub')); vi.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons', @@ -36,8 +31,14 @@ describe('ItemGroupTitleWithAction', () => { const rootView = tree!.root.findByType('View' as any); const children = React.Children.toArray(rootView.props.children) as any[]; - expect(children.map((c) => c.type)).toEqual(['Text', 'Pressable']); - expect(children[0]?.props?.children).toBe('Detected CLIs'); + expect(children).toHaveLength(2); + expect(children[1]?.type).toBe('Pressable'); + + const titleNodes = tree!.root.findAllByType('Text' as any).filter((node) => { + const value = node.props.children; + return Array.isArray(value) ? value.join('') === 'Detected CLIs' : value === 'Detected CLIs'; + }); + expect(titleNodes.length).toBeGreaterThan(0); }); it('renders title only when no action is provided', async () => { diff --git a/apps/ui/sources/components/ui/lists/ItemGroupTitleWithAction.tsx b/apps/ui/sources/components/ui/lists/ItemGroupTitleWithAction.tsx index a7b69055f..299784001 100644 --- a/apps/ui/sources/components/ui/lists/ItemGroupTitleWithAction.tsx +++ b/apps/ui/sources/components/ui/lists/ItemGroupTitleWithAction.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; -import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/ui/text/Text'; + export type ItemGroupTitleAction = { accessibilityLabel: string; diff --git a/apps/ui/sources/components/ui/lists/ItemList.popoverBoundary.test.tsx b/apps/ui/sources/components/ui/lists/ItemList.popoverBoundary.test.tsx new file mode 100644 index 000000000..3d23e6c4a --- /dev/null +++ b/apps/ui/sources/components/ui/lists/ItemList.popoverBoundary.test.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native-unistyles', () => { + const theme = { + dark: false, + colors: { + groupped: { background: '#ffffff', sectionTitle: '#888888' }, + surface: '#ffffff', + text: '#111111', + }, + }; + + return { + StyleSheet: { create: (factory: any) => factory(theme, {}) }, + useUnistyles: () => ({ theme, rt: { themeName: 'light' } }), + }; +}); + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'web', select: (m: any) => m?.web ?? m?.default ?? m?.ios }, + View: React.forwardRef((props: any, ref: any) => React.createElement('View', { ...props, ref }, props.children)), + ScrollView: React.forwardRef((props: any, ref: any) => React.createElement('ScrollView', { ...props, ref }, props.children)), + }; +}); + +vi.mock('@/constants/Typography', () => ({ + Typography: { default: () => ({}) }, +})); + +vi.mock('@/components/ui/layout/layout', () => ({ + layout: { maxWidth: 1024 }, +})); + +vi.mock('@/components/ui/text/Text', () => ({ + Text: (props: any) => React.createElement('Text', props, props.children), +})); + +describe('ItemList + ItemGroup popover boundary', () => { + it('prefers the screen/list boundary (ItemList) over the group boundary (ItemGroup)', async () => { + const { ItemList } = await import('./ItemList'); + const { ItemGroup } = await import('./ItemGroup'); + const { usePopoverBoundaryRef } = await import('@/components/ui/popover/PopoverBoundary'); + + const listBoundaryRef = React.createRef<any>(); + + let seenBoundaryRef: any = undefined; + function BoundarySpy() { + seenBoundaryRef = usePopoverBoundaryRef(); + return null; + } + + await act(async () => { + renderer.create( + <ItemList ref={listBoundaryRef}> + <ItemGroup title="Group" footer="Footer"> + <BoundarySpy /> + </ItemGroup> + </ItemList>, + ); + }); + + expect(seenBoundaryRef).toBe(listBoundaryRef); + }); + + it('still provides a fallback boundary when ItemGroup is rendered outside an ItemList', async () => { + const { ItemGroup } = await import('./ItemGroup'); + const { usePopoverBoundaryRef } = await import('@/components/ui/popover/PopoverBoundary'); + + let seenBoundaryRef: any = undefined; + function BoundarySpy() { + seenBoundaryRef = usePopoverBoundaryRef(); + return null; + } + + await act(async () => { + renderer.create( + <ItemGroup title="Group"> + <BoundarySpy /> + </ItemGroup>, + ); + }); + + expect(seenBoundaryRef).not.toBe(null); + expect(seenBoundaryRef).not.toBe(undefined); + }); +}); diff --git a/apps/ui/sources/components/ui/lists/ItemList.tsx b/apps/ui/sources/components/ui/lists/ItemList.tsx index a010322c0..04882b1e8 100644 --- a/apps/ui/sources/components/ui/lists/ItemList.tsx +++ b/apps/ui/sources/components/ui/lists/ItemList.tsx @@ -8,6 +8,7 @@ import { ScrollViewProps } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { PopoverBoundaryProvider } from '@/components/ui/popover/PopoverBoundary'; export interface ItemListProps extends ScrollViewProps { children: React.ReactNode; @@ -28,9 +29,25 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ }, })); +function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) { + if (typeof ref === 'function') { + ref(value); + return; + } + if (ref && typeof ref === 'object') { + (ref as React.MutableRefObject<T | null>).current = value; + } +} + +function isRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> { + return Boolean(ref && typeof ref === 'object' && 'current' in ref); +} + export const ItemList = React.memo(React.forwardRef<ScrollView, ItemListProps>((props, ref) => { const { theme } = useUnistyles(); const styles = stylesheet; + const internalRef = React.useRef<ScrollView>(null); + const boundaryRef = isRefObject(ref) ? ref : internalRef; const { children, @@ -46,26 +63,33 @@ export const ItemList = React.memo(React.forwardRef<ScrollView, ItemListProps>(( // Override background for non-inset grouped lists on iOS const backgroundColor = (isIOS && !insetGrouped) ? '#FFFFFF' : theme.colors.groupped.background; + const setRefs = React.useCallback((node: ScrollView | null) => { + internalRef.current = node; + setForwardedRef(ref, node); + }, [ref]); + return ( - <ScrollView - ref={ref} - style={[ - styles.container, - { backgroundColor }, - style - ]} - contentContainerStyle={[ - styles.contentContainer, - containerStyle - ]} - showsVerticalScrollIndicator={scrollViewProps.showsVerticalScrollIndicator !== undefined - ? scrollViewProps.showsVerticalScrollIndicator - : true} - contentInsetAdjustmentBehavior={(isIOS && !isWeb) ? 'automatic' : undefined} - {...scrollViewProps} - > - {children} - </ScrollView> + <PopoverBoundaryProvider boundaryRef={boundaryRef}> + <ScrollView + ref={setRefs} + style={[ + styles.container, + { backgroundColor }, + style + ]} + contentContainerStyle={[ + styles.contentContainer, + containerStyle + ]} + showsVerticalScrollIndicator={scrollViewProps.showsVerticalScrollIndicator !== undefined + ? scrollViewProps.showsVerticalScrollIndicator + : true} + contentInsetAdjustmentBehavior={(isIOS && !isWeb) ? 'automatic' : undefined} + {...scrollViewProps} + > + {children} + </ScrollView> + </PopoverBoundaryProvider> ); })); diff --git a/apps/ui/sources/components/ui/lists/ItemRowActions.test.ts b/apps/ui/sources/components/ui/lists/ItemRowActions.test.ts index 6324a356b..a304fe441 100644 --- a/apps/ui/sources/components/ui/lists/ItemRowActions.test.ts +++ b/apps/ui/sources/components/ui/lists/ItemRowActions.test.ts @@ -37,34 +37,11 @@ vi.mock('@expo/vector-icons', () => { }; }); -vi.mock('react-native-unistyles', () => { - const theme = { - dark: false, - colors: { - surface: '#ffffff', - surfacePressed: '#f1f1f1', - surfacePressedOverlay: '#f7f7f7', - divider: 'rgba(0,0,0,0.12)', - text: '#111111', - textSecondary: '#666666', - textDestructive: '#cc0000', - deleteAction: '#cc0000', - button: { secondary: { tint: '#111111' } }, - }, - }; - - return { - StyleSheet: { create: (factory: any) => factory(theme, {}) }, - useUnistyles: () => ({ - theme, - }), - }; -}); - vi.mock('react-native', () => { const React = require('react'); return { Platform: { OS: 'ios', select: (m: any) => m?.ios ?? m?.default }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, InteractionManager: { runAfterInteractions: () => {} }, useWindowDimensions: () => ({ width: 320, height: 800 }), StyleSheet: { diff --git a/apps/ui/sources/components/ui/lists/SelectableRow.cursor.spec.tsx b/apps/ui/sources/components/ui/lists/SelectableRow.cursor.spec.tsx index 5eec9008e..2b05b4efd 100644 --- a/apps/ui/sources/components/ui/lists/SelectableRow.cursor.spec.tsx +++ b/apps/ui/sources/components/ui/lists/SelectableRow.cursor.spec.tsx @@ -6,16 +6,12 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('react-native', () => ({ Platform: { OS: 'web', select: (values: any) => values?.default ?? values?.web ?? values?.ios ?? values?.android }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, Pressable: 'Pressable', Text: 'Text', View: 'View', })); -vi.mock('react-native-unistyles', () => ({ - useUnistyles: () => ({ theme: { colors: { surfacePressed: '#eee', surfacePressedOverlay: '#eee', divider: '#ddd', text: '#111', textSecondary: '#666', textDestructive: '#c00' } } }), - StyleSheet: { create: (fn: any) => fn({ colors: { surfacePressed: '#eee', surfacePressedOverlay: '#eee', divider: '#ddd', text: '#111', textSecondary: '#666', textDestructive: '#c00' } }, {}) }, -})); - vi.mock('@/constants/Typography', () => ({ Typography: { default: () => ({}) }, })); diff --git a/apps/ui/sources/components/ui/lists/SelectableRow.tsx b/apps/ui/sources/components/ui/lists/SelectableRow.tsx index 384f33b61..ff05f95d6 100644 --- a/apps/ui/sources/components/ui/lists/SelectableRow.tsx +++ b/apps/ui/sources/components/ui/lists/SelectableRow.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; -import { Platform, Pressable, Text, View, type StyleProp, type ViewStyle, type TextStyle } from 'react-native'; +import { Platform, Pressable, View, StyleProp, ViewStyle, TextStyle } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; +import { Text } from '@/components/ui/text/Text'; + export type SelectableRowVariant = 'slim' | 'default' | 'selectable'; @@ -63,14 +65,14 @@ const stylesheet = StyleSheet.create((theme) => ({ }, // Palette variant states (match old CommandPaletteItem styles exactly) rowSelectablePressed: { - backgroundColor: '#F5F5F5', + backgroundColor: theme.colors.surfacePressed, }, rowSelectableHovered: { - backgroundColor: '#F8F8F8', + backgroundColor: theme.dark ? theme.colors.surfaceHighest : theme.colors.surfaceHigh, }, rowSelectableSelected: { - backgroundColor: '#F0F7FF', - borderColor: '#007AFF20', + backgroundColor: theme.colors.surfaceSelected, + borderColor: theme.colors.accent.blue, }, rowDisabled: { opacity: 0.5, @@ -92,7 +94,7 @@ const stylesheet = StyleSheet.create((theme) => ({ letterSpacing: Platform.select({ ios: -0.2, default: 0 }), }, titleSelectable: { - color: '#000', + color: theme.colors.text, fontSize: 15, letterSpacing: -0.2, }, @@ -107,7 +109,7 @@ const stylesheet = StyleSheet.create((theme) => ({ lineHeight: 18, }, subtitleSelectable: { - color: '#666', + color: theme.colors.textSecondary, letterSpacing: -0.1, }, right: { diff --git a/apps/ui/sources/components/ui/media/CodeView.tsx b/apps/ui/sources/components/ui/media/CodeView.tsx index fb56b017e..efee3ca70 100644 --- a/apps/ui/sources/components/ui/media/CodeView.tsx +++ b/apps/ui/sources/components/ui/media/CodeView.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; -import { Text, View, Platform } from 'react-native'; +import { View, Platform } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; +import { Text } from '@/components/ui/text/Text'; + interface CodeViewProps { code: string; diff --git a/apps/ui/sources/components/ui/media/SimpleSyntaxHighlighter.tsx b/apps/ui/sources/components/ui/media/SimpleSyntaxHighlighter.tsx index de2cf0dd1..8eb9cdb60 100644 --- a/apps/ui/sources/components/ui/media/SimpleSyntaxHighlighter.tsx +++ b/apps/ui/sources/components/ui/media/SimpleSyntaxHighlighter.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { tokenizeSimpleSyntaxText } from '@/components/ui/code/tokenization/simpleSyntaxTokenizer'; +import { Text } from '@/components/ui/text/Text'; + interface SimpleSyntaxHighlighterProps { code: string; diff --git a/apps/ui/sources/components/ui/navigation/OAuthView.tsx b/apps/ui/sources/components/ui/navigation/OAuthView.tsx index e2b550ce5..195c7e358 100644 --- a/apps/ui/sources/components/ui/navigation/OAuthView.tsx +++ b/apps/ui/sources/components/ui/navigation/OAuthView.tsx @@ -1,6 +1,6 @@ import { parseOauthCallbackUrl, generatePkceCodes, generateOauthState, type PkceCodes } from '@/utils/auth/oauthCore'; import * as React from 'react'; -import { ActivityIndicator, Platform, Text, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, Platform, TouchableOpacity, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import Animated, { useSharedValue, @@ -11,6 +11,8 @@ import { runOnJS } from 'react-native-worklets'; import WebView from 'react-native-webview'; import { t } from '@/text'; import { Modal } from '@/modal'; +import { Text } from '@/components/ui/text/Text'; + const styles = StyleSheet.create((theme) => ({ container: { @@ -56,11 +58,11 @@ const styles = StyleSheet.create((theme) => ({ retryButton: { paddingHorizontal: 20, paddingVertical: 10, - backgroundColor: '#007AFF', + backgroundColor: theme.colors.accent.blue, borderRadius: 8, }, retryButtonText: { - color: '#FFFFFF', + color: theme.colors.button.primary.tint, fontSize: 16, fontWeight: '600', }, @@ -84,22 +86,22 @@ const styles = StyleSheet.create((theme) => ({ marginBottom: 24, }, terminalContainer: { - backgroundColor: '#1e1e1e', + backgroundColor: theme.colors.terminal.background, borderRadius: 8, padding: 16, minWidth: 280, borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.1)', + borderColor: theme.colors.modal.border, }, terminalPrompt: { fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', fontSize: 14, - color: '#00ff00', + color: theme.colors.terminal.prompt, }, terminalCommand: { fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', fontSize: 14, - color: '#ffffff', + color: theme.colors.terminal.command, }, })); diff --git a/apps/ui/sources/components/ui/navigation/TabBar.tsx b/apps/ui/sources/components/ui/navigation/TabBar.tsx index ff27dd388..9070c9881 100644 --- a/apps/ui/sources/components/ui/navigation/TabBar.tsx +++ b/apps/ui/sources/components/ui/navigation/TabBar.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Pressable, Text } from 'react-native'; +import { View, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Image } from 'expo-image'; @@ -8,6 +8,8 @@ import { Typography } from '@/constants/Typography'; import { layout } from '@/components/ui/layout/layout'; import { useInboxHasContent } from '@/hooks/inbox/useInboxHasContent'; import { useFriendsEnabled } from '@/hooks/server/useFriendsEnabled'; +import { Text } from '@/components/ui/text/Text'; + export type TabType = 'zen' | 'inbox' | 'sessions' | 'settings'; @@ -66,7 +68,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: 'center', }, badgeText: { - color: '#FFFFFF', + color: theme.colors.button.primary.tint, fontSize: 10, ...Typography.default('semiBold'), }, diff --git a/apps/ui/sources/components/ui/overlays/FloatingOverlay.arrow.test.ts b/apps/ui/sources/components/ui/overlays/FloatingOverlay.arrow.test.ts index b34c91b2a..4af70ff96 100644 --- a/apps/ui/sources/components/ui/overlays/FloatingOverlay.arrow.test.ts +++ b/apps/ui/sources/components/ui/overlays/FloatingOverlay.arrow.test.ts @@ -78,10 +78,13 @@ vi.mock('@/components/ui/scroll/ScrollEdgeIndicators', () => ({ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ useScrollEdgeFades: () => ({ + canScrollX: false, + canScrollY: false, visibility: { top: false, bottom: false, left: false, right: false }, onViewportLayout: () => {}, onContentSizeChange: () => {}, onScroll: () => {}, + onMomentumScrollEnd: () => {}, }), })); diff --git a/apps/ui/sources/components/ui/overlays/FloatingOverlay.tsx b/apps/ui/sources/components/ui/overlays/FloatingOverlay.tsx index 052a11080..e94aaee87 100644 --- a/apps/ui/sources/components/ui/overlays/FloatingOverlay.tsx +++ b/apps/ui/sources/components/ui/overlays/FloatingOverlay.tsx @@ -3,7 +3,7 @@ import { Platform, type StyleProp, type ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ScrollEdgeFades } from '@/components/ui/scroll/ScrollEdgeFades'; -import { useScrollEdgeFades } from '@/components/ui/scroll/useScrollEdgeFades'; +import { useScrollEdgeFades, type ScrollEdgeVisibility } from '@/components/ui/scroll/useScrollEdgeFades'; import { ScrollEdgeIndicators } from '@/components/ui/scroll/ScrollEdgeIndicators'; const stylesheet = StyleSheet.create((theme, runtime) => ({ @@ -59,6 +59,12 @@ interface FloatingOverlayProps { edgeIndicators?: boolean | Readonly<{ size?: number; opacity?: number }>; /** Optional arrow that points back to the anchor (useful for context menus). */ arrow?: FloatingOverlayArrow; + /** + * Initial visibility for scroll edge fades before measurement. + * Useful for optimistic trailing-edge fades (e.g., bottom: true for lists + * that typically have more content below). + */ + initialVisibility?: Partial<ScrollEdgeVisibility>; } export const FloatingOverlay = React.memo((props: FloatingOverlayProps) => { @@ -106,6 +112,7 @@ export const FloatingOverlay = React.memo((props: FloatingOverlayProps) => { }, overflowThreshold: 1, edgeThreshold: 1, + initialVisibility: props.initialVisibility, }); const arrowCfg = React.useMemo(() => { @@ -142,6 +149,7 @@ export const FloatingOverlay = React.memo((props: FloatingOverlayProps) => { onLayout={fadeCfg || indicatorCfg ? fades.onViewportLayout : undefined} onContentSizeChange={fadeCfg || indicatorCfg ? fades.onContentSizeChange : undefined} onScroll={fadeCfg || indicatorCfg ? fades.onScroll : undefined} + onMomentumScrollEnd={fadeCfg || indicatorCfg ? fades.onMomentumScrollEnd : undefined} > {children} </Animated.ScrollView> diff --git a/apps/ui/sources/components/ui/scroll/useScrollEdgeFades.ts b/apps/ui/sources/components/ui/scroll/useScrollEdgeFades.ts index ba9c2744f..758c5a8dc 100644 --- a/apps/ui/sources/components/ui/scroll/useScrollEdgeFades.ts +++ b/apps/ui/sources/components/ui/scroll/useScrollEdgeFades.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native'; export type ScrollEdge = 'top' | 'bottom' | 'left' | 'right'; @@ -20,6 +21,11 @@ export type UseScrollEdgeFadesParams = Readonly<{ * Distance from the edge before we show the fade (px). */ edgeThreshold?: number; + /** + * Initial visibility state before measurement. Useful for optimistic trailing-edge + * fades (e.g., bottom: true for lists that typically have more content below). + */ + initialVisibility?: Partial<ScrollEdgeVisibility>; }>; type Size = Readonly<{ width: number; height: number }>; @@ -36,6 +42,10 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { const overflowThreshold = params.overflowThreshold ?? 1; const edgeThreshold = params.edgeThreshold ?? 1; + const initialVisibility = React.useMemo<ScrollEdgeVisibility>(() => { + return { ...defaultVisibility, ...params.initialVisibility }; + }, [params.initialVisibility]); + const enabled = React.useMemo(() => { return { top: Boolean(params.enabledEdges.top), @@ -51,8 +61,9 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { const [canScroll, setCanScroll] = React.useState(() => ({ x: false, y: false })); - const visibilityRef = React.useRef<ScrollEdgeVisibility>(defaultVisibility); - const [visibility, setVisibility] = React.useState<ScrollEdgeVisibility>(defaultVisibility); + const visibilityRef = React.useRef<ScrollEdgeVisibility>(initialVisibility); + + const [visibility, setVisibility] = React.useState<ScrollEdgeVisibility>(initialVisibility); const recompute = React.useCallback(() => { const viewport = viewportRef.current; @@ -93,7 +104,7 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { }); }, [edgeThreshold, enabled.bottom, enabled.left, enabled.right, enabled.top, overflowThreshold]); - const onViewportLayout = React.useCallback((e: any) => { + const onViewportLayout = React.useCallback((e: LayoutChangeEvent) => { const width = e?.nativeEvent?.layout?.width ?? 0; const height = e?.nativeEvent?.layout?.height ?? 0; viewportRef.current = { width, height }; @@ -105,7 +116,7 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { recompute(); }, [recompute]); - const onScroll = React.useCallback((e: any) => { + const onScroll = React.useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => { const ne = e?.nativeEvent; if (!ne) return; @@ -131,6 +142,19 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { recompute(); }, [recompute]); + // Ensure final position is captured when momentum scroll ends. + // iOS/Android may not fire a final onScroll event at scroll boundaries. + const onMomentumScrollEnd = React.useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => { + const ne = e?.nativeEvent; + if (!ne) return; + + const x = ne.contentOffset?.x ?? 0; + const y = ne.contentOffset?.y ?? 0; + offsetRef.current = { x, y }; + + recompute(); + }, [recompute]); + return { canScrollX: canScroll.x, canScrollY: canScroll.y, @@ -138,6 +162,7 @@ export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { onViewportLayout, onContentSizeChange, onScroll, + onMomentumScrollEnd, } as const; } diff --git a/apps/ui/sources/components/ui/text/StyledText.tsx b/apps/ui/sources/components/ui/text/StyledText.tsx deleted file mode 100644 index 96a6fb7bb..000000000 --- a/apps/ui/sources/components/ui/text/StyledText.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { Text as RNText, TextProps as RNTextProps, StyleProp, TextStyle } from 'react-native'; -import { Typography } from '@/constants/Typography'; - -interface StyledTextProps extends RNTextProps { - /** - * Whether to use the default typography. Set to false to skip default font. - * Useful when you want to use a different typography style. - */ - useDefaultTypography?: boolean; - /** - * Whether the text should be selectable. Defaults to false. - */ - selectable?: boolean; -} - -export const Text: React.FC<StyledTextProps> = ({ - style, - useDefaultTypography = true, - selectable = false, - ...props -}) => { - const defaultStyle = useDefaultTypography ? Typography.default() : {}; - - return ( - <RNText - style={[defaultStyle, style]} - selectable={selectable} - {...props} - /> - ); -}; - -// Export the original RNText as well, in case it's needed -export { Text as RNText } from 'react-native'; \ No newline at end of file diff --git a/apps/ui/sources/components/ui/text/Text.tsx b/apps/ui/sources/components/ui/text/Text.tsx new file mode 100644 index 000000000..4513a65f3 --- /dev/null +++ b/apps/ui/sources/components/ui/text/Text.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { + Text as RNText, + TextInput as RNTextInput, + type TextInputProps as RNTextInputProps, + type TextProps as RNTextProps, + type TextStyle, +} from 'react-native'; + +import { Typography } from '@/constants/Typography'; +import { useLocalSetting } from '@/sync/store/hooks'; + +import { scaleTextStyle } from './uiFontScale'; + + +export type AppTextProps = RNTextProps & Readonly<{ + /** + * Whether to use the default typography. Set to false to skip the default font. + * Useful when you want to control typography via `style` (e.g. `Typography.mono()`). + */ + useDefaultTypography?: boolean; + /** Whether the text should be selectable. Defaults to false. */ + selectable?: boolean; + /** Escape hatch for special surfaces (defaults to false). */ + disableUiFontScaling?: boolean; +}>; + +export const Text = React.memo( + React.forwardRef<any, AppTextProps>(function AppText( + { + style, + useDefaultTypography = true, + selectable = false, + disableUiFontScaling = false, + ...props + }, + ref + ) { + const uiFontScaleSetting = useLocalSetting('uiFontScale'); + const uiFontScale = disableUiFontScaling ? 1 : uiFontScaleSetting; + + const scaledStyle = React.useMemo(() => scaleTextStyle(style as any, uiFontScale), [style, uiFontScale]); + const defaultStyle = useDefaultTypography ? Typography.default() : null; + const mergedStyle = React.useMemo(() => { + const out: any[] = []; + if (defaultStyle) out.push(defaultStyle); + if (Array.isArray(scaledStyle)) out.push(...scaledStyle); + else if (scaledStyle) out.push(scaledStyle); + return out; + }, [defaultStyle, scaledStyle]); + + return ( + <RNText + ref={ref} + style={mergedStyle} + selectable={selectable} + {...props} + /> + ); + }) +); + +export type AppTextInputProps = RNTextInputProps & Readonly<{ + useDefaultTypography?: boolean; + disableUiFontScaling?: boolean; +}>; + +export const TextInput = React.memo( + React.forwardRef<any, AppTextInputProps>(function AppTextInput( + { style, useDefaultTypography = true, disableUiFontScaling = false, ...props }, + ref + ) { + const uiFontScaleSetting = useLocalSetting('uiFontScale'); + const uiFontScale = disableUiFontScaling ? 1 : uiFontScaleSetting; + + const scaledStyle = React.useMemo(() => scaleTextStyle(style as any, uiFontScale) as TextStyle, [style, uiFontScale]); + const defaultStyle = useDefaultTypography ? Typography.default() : null; + const mergedStyle = React.useMemo(() => { + const out: any[] = []; + if (defaultStyle) out.push(defaultStyle); + if (Array.isArray(scaledStyle)) out.push(...scaledStyle); + else if (scaledStyle) out.push(scaledStyle); + return out; + }, [defaultStyle, scaledStyle]); + + return ( + <RNTextInput + ref={ref} + style={mergedStyle} + {...props} + /> + ); + }) +); diff --git a/apps/ui/sources/components/ui/text/uiFontScale.test.ts b/apps/ui/sources/components/ui/text/uiFontScale.test.ts new file mode 100644 index 000000000..69d379b31 --- /dev/null +++ b/apps/ui/sources/components/ui/text/uiFontScale.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { scaleTextStyle } from './uiFontScale'; + +describe('uiFontScale', () => { + it('scales fontSize, lineHeight, and letterSpacing', () => { + const tokenStyle = { unistyles_abc: 1, color: 'red' } as any; + const scaled = scaleTextStyle( + [ + tokenStyle, + { fontSize: 10, lineHeight: 12, letterSpacing: -0.5 }, + ] as any, + 1.2, + ) as any; + + expect(Array.isArray(scaled)).toBe(true); + expect(scaled[0]).toBe(tokenStyle); + expect(scaled[1]).toMatchObject({ + fontSize: 12, + lineHeight: 14.4, + letterSpacing: -0.6, + }); + }); + + it('preserves non-enumerable metadata when scaling', () => { + const marker = Symbol('marker'); + const style: any = { fontSize: 10 }; + Object.defineProperty(style, marker, { value: { className: 'unistyles_x' }, enumerable: false }); + + const scaled = scaleTextStyle(style, 1.2) as any; + expect(scaled.fontSize).toBe(12); + expect(Object.getOwnPropertySymbols(scaled)).toContain(marker); + expect(scaled[marker]).toEqual({ className: 'unistyles_x' }); + }); + + it('does not crash on nullish styles', () => { + expect(scaleTextStyle(null, 1.1)).toBe(null); + expect(scaleTextStyle(undefined, 1.1)).toBe(undefined); + }); + + it('returns the original style reference when there is nothing to scale', () => { + const style = [{ color: 'red' }, { fontFamily: 'Inter-Regular' }]; + expect(scaleTextStyle(style, 1.2)).toBe(style); + }); +}); diff --git a/apps/ui/sources/components/ui/text/uiFontScale.ts b/apps/ui/sources/components/ui/text/uiFontScale.ts new file mode 100644 index 000000000..f50ffc926 --- /dev/null +++ b/apps/ui/sources/components/ui/text/uiFontScale.ts @@ -0,0 +1,63 @@ +import type { StyleProp, TextStyle } from 'react-native'; + +function roundTo2(value: number): number { + return Math.round(value * 100) / 100; +} + +function clonePreservingOwnProps<T extends object>(entry: T): T { + try { + const proto = Object.getPrototypeOf(entry); + const descriptors = Object.getOwnPropertyDescriptors(entry); + return Object.create(proto, descriptors); + } catch { + return { ...(entry as any) }; + } +} + +export function scaleTextStyle<T extends StyleProp<TextStyle> | undefined | null>( + style: T, + uiFontScale: number +): T { + if (style == null) return style; + if (typeof uiFontScale !== 'number' || !Number.isFinite(uiFontScale) || uiFontScale === 1) return style; + + const scaleOne = (entry: any): any => { + if (!entry) return entry; + if (typeof entry === 'number') { + // Numeric style ids come from React Native's internal style registry, which isn't available + // in this codebase (we avoid React Native's StyleSheet API in favor of Unistyles). + // Fail closed and preserve the original value. + return entry; + } + if (typeof entry !== 'object') return entry; + + const hasFontSize = typeof (entry as any).fontSize === 'number'; + const hasLineHeight = typeof (entry as any).lineHeight === 'number'; + const hasLetterSpacing = typeof (entry as any).letterSpacing === 'number'; + if (!hasFontSize && !hasLineHeight && !hasLetterSpacing) return entry; + + const next: any = clonePreservingOwnProps(entry as any); + try { + if (hasFontSize) next.fontSize = roundTo2(next.fontSize * uiFontScale); + if (hasLineHeight) next.lineHeight = roundTo2(next.lineHeight * uiFontScale); + if (hasLetterSpacing) next.letterSpacing = roundTo2(next.letterSpacing * uiFontScale); + return next; + } catch { + // If the style object is non-writable (or uses accessors), avoid corrupting opaque metadata. + return entry; + } + }; + + if (Array.isArray(style)) { + let changed = false; + const next = (style as any[]).map((entry) => { + const scaled = scaleOne(entry); + if (scaled !== entry) changed = true; + return scaled; + }); + return (changed ? (next as any) : style) as any; + } + + const scaled = scaleOne(style); + return (scaled === style ? style : scaled) as any; +} diff --git a/apps/ui/sources/components/ui/text/useWebUiFontScale.ts b/apps/ui/sources/components/ui/text/useWebUiFontScale.ts new file mode 100644 index 000000000..dc0e31035 --- /dev/null +++ b/apps/ui/sources/components/ui/text/useWebUiFontScale.ts @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; + +import { useLocalSetting } from '@/sync/store/hooks'; + +import { + ensureOverrideStyleElement, + scanDocumentForUnistylesFontMetrics, + setRootCssVar, + syncOverrideStyleElement, + type UnistylesFontMetric, +} from './webUnistylesFontOverrides'; + +let webOverrideObserverInstalled = false; +const webUnistylesFontMetricsCache = new Map<string, UnistylesFontMetric>(); + +function scheduleSync() { + if (typeof document === 'undefined') return; + const g = globalThis as any; + if (g.__HAPPIER_WEB_UI_FONT_SCALE_SYNC_SCHEDULED__) return; + g.__HAPPIER_WEB_UI_FONT_SCALE_SYNC_SCHEDULED__ = true; + + const run = () => { + g.__HAPPIER_WEB_UI_FONT_SCALE_SYNC_SCHEDULED__ = false; + try { + const added = scanDocumentForUnistylesFontMetrics(document, webUnistylesFontMetricsCache); + if (added <= 0) return; + const styleEl = ensureOverrideStyleElement(document); + syncOverrideStyleElement(styleEl, webUnistylesFontMetricsCache); + } catch { + // best-effort only + } + }; + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(run); + return; + } + setTimeout(run, 0); +} + +function installObserverOnce() { + if (webOverrideObserverInstalled) return; + if (typeof document === 'undefined') return; + + webOverrideObserverInstalled = true; + + try { + // Some CSS-in-JS engines (including Unistyles) add new rules via `CSSStyleSheet.insertRule`, + // which does not mutate DOM nodes. Patch insertRule to schedule a rescan when new rules land. + const g = globalThis as any; + if (!g.__HAPPIER_WEB_UI_FONT_SCALE_INSERT_RULE_PATCHED__ && typeof CSSStyleSheet !== 'undefined') { + const proto = (CSSStyleSheet as any)?.prototype; + const original = proto?.insertRule; + if (typeof original === 'function') { + g.__HAPPIER_WEB_UI_FONT_SCALE_INSERT_RULE_PATCHED__ = true; + proto.insertRule = function patchedInsertRule(this: CSSStyleSheet, ...args: any[]) { + const result = original.apply(this, args); + scheduleSync(); + return result; + }; + } + } + + // Fallback: also observe <style> / <link> changes in case the engine swaps sheets. + if (typeof MutationObserver === 'function') { + const observer = new MutationObserver(() => { + scheduleSync(); + }); + observer.observe(document.head, { childList: true, subtree: true }); + } + } catch { + // ignore + } +} + +/** + * Web-only: Unistyles compiles many font-related styles into static CSS classes (no numeric `fontSize` + * values remain in React `style` props), so per-component JS scaling cannot reliably adjust font sizes. + * + * This hook: + * - sets a root CSS variable for the chosen UI font scale + * - generates an override stylesheet that scales `.unistyles_*` font rules via `calc(... * var(--scale))` + * - keeps the override sheet up-to-date as new Unistyles classes are injected at runtime + */ +export function useWebUiFontScale() { + const scale = useLocalSetting('uiFontScale'); + + React.useEffect(() => { + if (Platform.OS !== 'web') return; + if (typeof document === 'undefined') return; + + // One-time setup: create override sheet + initial scan/sync + observers for late-injected rules. + const styleEl = ensureOverrideStyleElement(document); + scanDocumentForUnistylesFontMetrics(document, webUnistylesFontMetricsCache); + syncOverrideStyleElement(styleEl, webUnistylesFontMetricsCache); + installObserverOnce(); + + // In practice, Unistyles can inject styles a tick after mount/render; do a couple extra passes. + scheduleSync(); + const t = setTimeout(scheduleSync, 50); + return () => clearTimeout(t); + }, []); + + React.useEffect(() => { + if (Platform.OS !== 'web') return; + if (typeof document === 'undefined') return; + setRootCssVar(document, scale); + }, [scale]); +} diff --git a/apps/ui/sources/components/ui/text/webUnistylesFontOverrides.test.ts b/apps/ui/sources/components/ui/text/webUnistylesFontOverrides.test.ts new file mode 100644 index 000000000..862a35453 --- /dev/null +++ b/apps/ui/sources/components/ui/text/webUnistylesFontOverrides.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; + +import { + scanDocumentForUnistylesFontMetrics, + ensureOverrideStyleElement, + syncOverrideStyleElement, + setRootCssVar, + HAPPIER_UI_FONT_SCALE_CSS_VAR, + HAPPIER_UI_FONT_OVERRIDE_STYLE_ELEMENT_ID, +} from './webUnistylesFontOverrides'; + +describe('webUnistylesFontOverrides', () => { + it('generates calc(var(--scale)) overrides for Unistyles font rules', () => { + const dom = new JSDOM(`<!doctype html><html><head></head><body></body></html>`); + const { document } = dom.window; + + const styleTag = document.createElement('style'); + styleTag.textContent = ` + .unistyles_a1 { font-size: 16px; line-height: 24px; letter-spacing: 0.15px; color: red; } + .unistyles_b2 { font-size: 14px; } + `; + document.head.appendChild(styleTag); + + const metrics = new Map<string, { fontSize?: string; lineHeight?: string; letterSpacing?: string }>(); + const added = scanDocumentForUnistylesFontMetrics(document, metrics); + expect(added).toBe(2); + + const overrideEl = ensureOverrideStyleElement(document); + const appended = syncOverrideStyleElement(overrideEl, metrics); + expect(appended).toBe(2); + + const css = overrideEl.textContent ?? ''; + expect(css).toContain(`.${'unistyles_a1'}`); + expect(css).toContain(`font-size: calc(16px * var(${HAPPIER_UI_FONT_SCALE_CSS_VAR}))`); + expect(css).toContain(`line-height: calc(24px * var(${HAPPIER_UI_FONT_SCALE_CSS_VAR}))`); + expect(css).toContain(`letter-spacing: calc(0.15px * var(${HAPPIER_UI_FONT_SCALE_CSS_VAR}))`); + + expect(css).toContain(`.${'unistyles_b2'}`); + expect(css).toContain(`font-size: calc(14px * var(${HAPPIER_UI_FONT_SCALE_CSS_VAR}))`); + }); + + it('sets the root CSS variable to the provided scale', () => { + const dom = new JSDOM(`<!doctype html><html><head></head><body></body></html>`); + const { document } = dom.window; + + setRootCssVar(document, 1.1); + const raw = document.documentElement.style.getPropertyValue(HAPPIER_UI_FONT_SCALE_CSS_VAR); + expect(raw.trim()).toBe('1.1'); + }); + + it('does not duplicate override rules when synced repeatedly', () => { + const dom = new JSDOM(`<!doctype html><html><head></head><body></body></html>`); + const { document } = dom.window; + + const styleTag = document.createElement('style'); + styleTag.textContent = `.unistyles_a1 { font-size: 16px; }`; + document.head.appendChild(styleTag); + + const metrics = new Map<string, { fontSize?: string; lineHeight?: string; letterSpacing?: string }>(); + scanDocumentForUnistylesFontMetrics(document, metrics); + + const overrideEl = ensureOverrideStyleElement(document); + expect(overrideEl.id).toBe(HAPPIER_UI_FONT_OVERRIDE_STYLE_ELEMENT_ID); + + const appended1 = syncOverrideStyleElement(overrideEl, metrics); + const appended2 = syncOverrideStyleElement(overrideEl, metrics); + expect(appended1).toBe(1); + expect(appended2).toBe(0); + + const css = overrideEl.textContent ?? ''; + const occurrences = css.split('.unistyles_a1').length - 1; + expect(occurrences).toBe(1); + }); +}); diff --git a/apps/ui/sources/components/ui/text/webUnistylesFontOverrides.ts b/apps/ui/sources/components/ui/text/webUnistylesFontOverrides.ts new file mode 100644 index 000000000..ef3f24fd3 --- /dev/null +++ b/apps/ui/sources/components/ui/text/webUnistylesFontOverrides.ts @@ -0,0 +1,173 @@ +export const HAPPIER_UI_FONT_SCALE_CSS_VAR = '--happier-ui-font-scale'; +export const HAPPIER_UI_FONT_OVERRIDE_STYLE_ELEMENT_ID = 'happier-ui-font-scale-overrides'; + +export type UnistylesFontMetric = Readonly<{ + fontSize?: string; + lineHeight?: string; + letterSpacing?: string; +}>; + +function isCssStyleRule(rule: CSSRule): rule is CSSStyleRule { + return (rule as any)?.type === 1; +} + +function isCssGroupingRule(rule: CSSRule): rule is CSSGroupingRule { + const type = (rule as any)?.type; + // CSSMediaRule (4), CSSSupportsRule (12), etc. + return typeof (rule as any)?.cssRules !== 'undefined' && type !== 1; +} + +function extractUnistylesClassNames(selectorText: string): string[] { + // `.unistyles_xxx` style rules are emitted by Unistyles on web. + // A selector may contain multiple classes and/or be a comma-separated group. + const matches = selectorText.match(/\.unistyles_[A-Za-z0-9_-]+/g) ?? []; + const out: string[] = []; + for (const m of matches) { + const cls = m.startsWith('.') ? m.slice(1) : m; + if (cls && !out.includes(cls)) out.push(cls); + } + return out; +} + +function isPx(value: string): boolean { + return typeof value === 'string' && /^\s*-?\d+(\.\d+)?px\s*$/.test(value); +} + +function isUnitlessNumber(value: string): boolean { + return typeof value === 'string' && /^\s*-?\d+(\.\d+)?\s*$/.test(value); +} + +function normalizeCssNumber(value: string): string | null { + const trimmed = String(value ?? '').trim(); + if (!trimmed) return null; + if (isPx(trimmed)) return trimmed.replace(/\s+/g, ''); + if (isUnitlessNumber(trimmed)) return trimmed; + return null; +} + +function pickMetricFromRule(rule: CSSStyleRule): UnistylesFontMetric | null { + const fontSize = normalizeCssNumber(rule.style.fontSize || rule.style.getPropertyValue('font-size')); + const lineHeight = normalizeCssNumber(rule.style.lineHeight || rule.style.getPropertyValue('line-height')); + const letterSpacing = normalizeCssNumber(rule.style.letterSpacing || rule.style.getPropertyValue('letter-spacing')); + + if (!fontSize && !lineHeight && !letterSpacing) return null; + return { + ...(fontSize ? { fontSize } : null), + ...(lineHeight ? { lineHeight } : null), + ...(letterSpacing ? { letterSpacing } : null), + }; +} + +export function scanDocumentForUnistylesFontMetrics( + document: Document, + cache: Map<string, UnistylesFontMetric>, +): number { + let added = 0; + if (!(document as any)?.styleSheets) return 0; + + const walkRules = (rules: CSSRuleList) => { + for (let i = 0; i < rules.length; i++) { + const rule = rules[i] as CSSRule; + if (isCssStyleRule(rule)) { + const selectorText = rule.selectorText ?? ''; + if (!selectorText.includes('unistyles_')) continue; + const metric = pickMetricFromRule(rule); + if (!metric) continue; + const classNames = extractUnistylesClassNames(selectorText); + for (const cls of classNames) { + if (cache.has(cls)) continue; + cache.set(cls, metric); + added += 1; + } + continue; + } + if (isCssGroupingRule(rule)) { + walkRules((rule as any).cssRules as CSSRuleList); + } + } + }; + + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList | null = null; + try { + rules = sheet.cssRules; + } catch { + // Some stylesheets can be cross-origin or otherwise inaccessible. + rules = null; + } + if (!rules) continue; + walkRules(rules); + } + + return added; +} + +export function ensureOverrideStyleElement(document: Document): HTMLStyleElement { + const existing = document.getElementById(HAPPIER_UI_FONT_OVERRIDE_STYLE_ELEMENT_ID); + if (existing && existing.tagName.toLowerCase() === 'style') { + return existing as HTMLStyleElement; + } + + const style = document.createElement('style'); + style.id = HAPPIER_UI_FONT_OVERRIDE_STYLE_ELEMENT_ID; + style.setAttribute?.('data-happier', 'ui-font-scale-overrides'); + document.head.appendChild(style); + return style; +} + +function getInjectedSet(styleEl: HTMLStyleElement): Set<string> { + const anyEl = styleEl as any; + if (!anyEl.__happierInjectedUnistylesFontClasses) { + anyEl.__happierInjectedUnistylesFontClasses = new Set<string>(); + } + return anyEl.__happierInjectedUnistylesFontClasses as Set<string>; +} + +function buildOverrideRule(className: string, metric: UnistylesFontMetric): string { + const parts: string[] = []; + if (metric.fontSize) { + parts.push(`font-size: calc(${metric.fontSize} * var(${HAPPIER_UI_FONT_SCALE_CSS_VAR})) !important;`); + } + if (metric.lineHeight) { + parts.push(`line-height: calc(${metric.lineHeight} * var(${HAPPIER_UI_FONT_SCALE_CSS_VAR})) !important;`); + } + if (metric.letterSpacing) { + parts.push(`letter-spacing: calc(${metric.letterSpacing} * var(${HAPPIER_UI_FONT_SCALE_CSS_VAR})) !important;`); + } + if (parts.length === 0) return ''; + return `.${className} { ${parts.join(' ')} }\n`; +} + +export function syncOverrideStyleElement( + styleEl: HTMLStyleElement, + cache: Map<string, UnistylesFontMetric>, +): number { + const injected = getInjectedSet(styleEl); + let appended = 0; + let next = styleEl.textContent ?? ''; + + for (const [className, metric] of cache.entries()) { + if (injected.has(className)) continue; + const rule = buildOverrideRule(className, metric); + if (!rule) continue; + injected.add(className); + next += rule; + appended += 1; + } + + if (appended > 0) { + styleEl.textContent = next; + } + + return appended; +} + +export function setRootCssVar(document: Document, scale: number): void { + const root = (document as any)?.documentElement; + if (!root?.style?.setProperty) return; + const value = + typeof scale === 'number' && Number.isFinite(scale) + ? String(scale) + : '1'; + root.style.setProperty(HAPPIER_UI_FONT_SCALE_CSS_VAR, value); +} diff --git a/apps/ui/sources/components/usage/UsageBar.tsx b/apps/ui/sources/components/usage/UsageBar.tsx index 568176b8d..856d47ee1 100644 --- a/apps/ui/sources/components/usage/UsageBar.tsx +++ b/apps/ui/sources/components/usage/UsageBar.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; interface UsageBarProps { diff --git a/apps/ui/sources/components/usage/UsageChart.tsx b/apps/ui/sources/components/usage/UsageChart.tsx index 93eca6c14..3b31b16af 100644 --- a/apps/ui/sources/components/usage/UsageChart.tsx +++ b/apps/ui/sources/components/usage/UsageChart.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, ScrollView, Pressable } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { UsageDataPoint } from '@/sync/api/account/apiUsage'; import { t } from '@/text'; diff --git a/apps/ui/sources/components/usage/UsagePanel.tsx b/apps/ui/sources/components/usage/UsagePanel.tsx index 7ecd7bf4c..4cd071554 100644 --- a/apps/ui/sources/components/usage/UsagePanel.tsx +++ b/apps/ui/sources/components/usage/UsagePanel.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { View, ActivityIndicator, ScrollView, Pressable } from 'react-native'; -import { Text } from '@/components/ui/text/StyledText'; +import { Text } from '@/components/ui/text/Text'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useAuth } from '@/auth/context/AuthContext'; import { Item } from '@/components/ui/lists/Item'; @@ -179,7 +179,7 @@ export const UsagePanel: React.FC<{ sessionId?: string }> = ({ sessionId }) => { if (loading) { return ( <View style={styles.loadingContainer}> - <ActivityIndicator size="large" color="#007AFF" /> + <ActivityIndicator size="large" color={theme.colors.accent.blue} /> </View> ); } @@ -272,7 +272,7 @@ export const UsagePanel: React.FC<{ sessionId?: string }> = ({ sessionId }) => { label={model} value={tokens} maxValue={maxModelTokens} - color="#007AFF" + color={theme.colors.accent.blue} /> ))} </View> @@ -280,4 +280,4 @@ export const UsagePanel: React.FC<{ sessionId?: string }> = ({ sessionId }) => { )} </ScrollView> ); -}; \ No newline at end of file +}; diff --git a/apps/ui/sources/components/voice/surface/VoiceSurface.test.tsx b/apps/ui/sources/components/voice/surface/VoiceSurface.test.tsx index 8f01316f9..166e09f16 100644 --- a/apps/ui/sources/components/voice/surface/VoiceSurface.test.tsx +++ b/apps/ui/sources/components/voice/surface/VoiceSurface.test.tsx @@ -71,6 +71,7 @@ vi.mock('@/sync/domains/state/storage', () => ({ const allSessionsState: { current: any[] } = { current: [] }; vi.mock('@/sync/store/hooks', () => ({ useAllSessions: () => allSessionsState.current, + useLocalSetting: () => 1, })); const teleportSpy = vi.fn(async (_args: any) => ({ ok: true })); diff --git a/apps/ui/sources/components/voice/surface/VoiceSurface.tsx b/apps/ui/sources/components/voice/surface/VoiceSurface.tsx index ecb205d4f..de8c9d91b 100644 --- a/apps/ui/sources/components/voice/surface/VoiceSurface.tsx +++ b/apps/ui/sources/components/voice/surface/VoiceSurface.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, Pressable, ScrollView, Text, View } from 'react-native'; +import { Platform, Pressable, ScrollView, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Image } from 'expo-image'; @@ -21,6 +21,8 @@ import { hydrateVoiceAgentActivityFromCarrierSession } from '@/voice/persistence import { teleportVoiceAgentToSessionRoot } from '@/voice/agent/teleportVoiceAgentToSessionRoot'; import { getSessionName } from '@/utils/sessions/sessionUtils'; import { fireAndForget } from '@/utils/system/fireAndForget'; +import { Text } from '@/components/ui/text/Text'; + export type VoiceSurfaceVariant = 'sidebar' | 'session'; diff --git a/apps/ui/sources/components/zen/navigation/ZenHeader.tsx b/apps/ui/sources/components/zen/navigation/ZenHeader.tsx index 553ada8ba..cb1ee7a72 100644 --- a/apps/ui/sources/components/zen/navigation/ZenHeader.tsx +++ b/apps/ui/sources/components/zen/navigation/ZenHeader.tsx @@ -7,9 +7,11 @@ import { useIsTablet } from '@/utils/platform/responsive'; import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; import * as React from 'react'; -import { View, Text, Pressable } from 'react-native'; +import { View, Pressable } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import Ionicons from '@expo/vector-icons/Ionicons'; +import { Text } from '@/components/ui/text/Text'; + export const ZenHeader = React.memo(() => { const isTablet = useIsTablet(); diff --git a/apps/ui/sources/components/zen/screens/ZenAdd.tsx b/apps/ui/sources/components/zen/screens/ZenAdd.tsx index fc66a132a..fcd81f4ad 100644 --- a/apps/ui/sources/components/zen/screens/ZenAdd.tsx +++ b/apps/ui/sources/components/zen/screens/ZenAdd.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, TextInput, KeyboardAvoidingView, Platform } from 'react-native'; +import { View, KeyboardAvoidingView, Platform } from 'react-native'; import { useRouter } from 'expo-router'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -7,6 +7,8 @@ import { Typography } from '@/constants/Typography'; import { addTodo } from '@/sync/domains/todos/todoOps'; import { useAuth } from '@/auth/context/AuthContext'; import { t } from '@/text'; +import { TextInput } from '@/components/ui/text/Text'; + export const ZenAdd = React.memo(() => { const router = useRouter(); diff --git a/apps/ui/sources/components/zen/screens/ZenHome.tsx b/apps/ui/sources/components/zen/screens/ZenHome.tsx index 663b331f5..0544ad274 100644 --- a/apps/ui/sources/components/zen/screens/ZenHome.tsx +++ b/apps/ui/sources/components/zen/screens/ZenHome.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, ScrollView, Text, Platform } from 'react-native'; +import { View, ScrollView, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { layout } from '@/components/ui/layout/layout'; import { ZenHeader } from '@/components/zen/navigation/ZenHeader'; @@ -13,6 +13,8 @@ import { useShallow } from 'zustand/react/shallow'; import { VoiceSurface } from '@/components/voice/surface/VoiceSurface'; import { t } from '@/text'; import { useFeatureEnabled } from '@/hooks/server/useFeatureEnabled'; +import { Text } from '@/components/ui/text/Text'; + export const ZenHome = () => { const insets = useSafeAreaInsets(); diff --git a/apps/ui/sources/components/zen/screens/ZenView.tsx b/apps/ui/sources/components/zen/screens/ZenView.tsx index 3fcc39263..bffde97a1 100644 --- a/apps/ui/sources/components/zen/screens/ZenView.tsx +++ b/apps/ui/sources/components/zen/screens/ZenView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, ScrollView, TextInput, KeyboardAvoidingView, Platform } from 'react-native'; +import { View, ScrollView, KeyboardAvoidingView, Platform } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -16,6 +16,8 @@ import { toCamelCase } from '@/utils/strings/stringUtils'; import { removeTaskLinks, getSessionsForTask } from '@/sync/domains/todos/taskSessionLink'; import { t } from '@/text'; import { DEFAULT_AGENT_ID } from '@/agents/catalog/catalog'; +import { Text, TextInput } from '@/components/ui/text/Text'; + export const ZenView = React.memo(() => { const router = useRouter(); @@ -173,7 +175,7 @@ export const ZenView = React.memo(() => { ]} > {todo.done && ( - <Ionicons name="checkmark" size={20} color="#FFFFFF" /> + <Ionicons name="checkmark" size={20} color={theme.colors.button.primary.tint} /> )} </Pressable> @@ -218,7 +220,7 @@ export const ZenView = React.memo(() => { onPress={handleWorkOnTask} style={[styles.actionButton, { backgroundColor: theme.colors.button.primary.background }]} > - <Ionicons name="hammer-outline" size={20} color="#FFFFFF" /> + <Ionicons name="hammer-outline" size={20} color={theme.colors.button.primary.tint} /> <Text style={styles.actionButtonText}>{t('zen.view.workOnTask')}</Text> </Pressable> @@ -234,7 +236,7 @@ export const ZenView = React.memo(() => { onPress={handleDelete} style={[styles.actionButton, { backgroundColor: theme.colors.textDestructive }]} > - <Ionicons name="trash-outline" size={20} color="#FFFFFF" /> + <Ionicons name="trash-outline" size={20} color={theme.colors.button.primary.tint} /> <Text style={styles.actionButtonText}>{t('zen.view.delete')}</Text> </Pressable> </View> diff --git a/apps/ui/sources/components/zen/views/TodoView.tsx b/apps/ui/sources/components/zen/views/TodoView.tsx index 321bddc03..c4a6b31ef 100644 --- a/apps/ui/sources/components/zen/views/TodoView.tsx +++ b/apps/ui/sources/components/zen/views/TodoView.tsx @@ -1,9 +1,11 @@ import { MaterialCommunityIcons, Ionicons } from '@expo/vector-icons'; import * as React from 'react'; -import { Platform, Text, View, Pressable } from 'react-native'; +import { Platform, View, Pressable } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import { useRouter } from 'expo-router'; import { SharedValue, useAnimatedReaction, runOnJS } from 'react-native-reanimated'; +import { Text } from '@/components/ui/text/Text'; + export const TODO_HEIGHT = 56; @@ -73,7 +75,7 @@ export const TodoView = React.memo<TodoViewProps>((props) => { }} > {props.done && ( - <Ionicons name="checkmark" size={16} color="#FFFFFF" /> + <Ionicons name="checkmark" size={16} color={theme.colors.button.primary.tint} /> )} </Pressable> <View style={{ flex: 1, flexDirection: 'row' }}> @@ -111,4 +113,4 @@ export const TodoView = React.memo<TodoViewProps>((props) => { )} </Pressable> ); -}); \ No newline at end of file +}); diff --git a/apps/ui/sources/constants/PermissionModes.ts b/apps/ui/sources/constants/PermissionModes.ts index b45e0b326..352abac9a 100644 --- a/apps/ui/sources/constants/PermissionModes.ts +++ b/apps/ui/sources/constants/PermissionModes.ts @@ -1,12 +1,5 @@ -export const PERMISSION_MODES = [ - 'default', - 'acceptEdits', - 'bypassPermissions', - 'plan', - 'read-only', - 'safe-yolo', - 'yolo', -] as const; +import { SESSION_PERMISSION_MODES } from '@happier-dev/protocol'; -export type PermissionMode = (typeof PERMISSION_MODES)[number]; +export const PERMISSION_MODES = SESSION_PERMISSION_MODES; +export type PermissionMode = (typeof PERMISSION_MODES)[number]; diff --git a/apps/ui/sources/dev/jsdom.d.ts b/apps/ui/sources/dev/jsdom.d.ts new file mode 100644 index 000000000..e30b326a2 --- /dev/null +++ b/apps/ui/sources/dev/jsdom.d.ts @@ -0,0 +1,7 @@ +declare module 'jsdom' { + export class JSDOM { + constructor(html?: string, options?: any); + window: any; + } +} + diff --git a/apps/ui/sources/dev/reactNativeStub.ts b/apps/ui/sources/dev/reactNativeStub.ts index 380e4b87e..667334054 100644 --- a/apps/ui/sources/dev/reactNativeStub.ts +++ b/apps/ui/sources/dev/reactNativeStub.ts @@ -13,6 +13,7 @@ export const SectionList = 'SectionList' as any; export const Pressable = 'Pressable' as any; export const TouchableOpacity = 'TouchableOpacity' as any; export const TouchableWithoutFeedback = 'TouchableWithoutFeedback' as any; +export const RefreshControl = 'RefreshControl' as any; export const TextInput = 'TextInput' as any; export const ActivityIndicator = 'ActivityIndicator' as any; export const Switch = 'Switch' as any; @@ -36,7 +37,18 @@ export const Linking = { canOpenURL: async () => true, openURL: async () => {}, } as const; -export const StyleSheet = { create: (styles: any) => styles } as const; +function flattenStyle(style: any): any { + if (style == null) return style; + if (Array.isArray(style)) { + return style.reduce((acc, entry) => ({ ...acc, ...(flattenStyle(entry) ?? {}) }), {}); + } + if (typeof style === 'number') return {}; + if (typeof style === 'object') return style; + return {}; +} +export const StyleSheet = { create: (styles: any) => styles, flatten: flattenStyle } as const; +// Many components spread this object into style definitions. +(StyleSheet as any).absoluteFillObject = {}; export const TurboModuleRegistry = { getEnforcing: () => ({}) } as const; export const registerCallableModule = () => {}; diff --git a/apps/ui/sources/dev/testkit/rootLayoutTestkit.ts b/apps/ui/sources/dev/testkit/rootLayoutTestkit.ts index 4fc2c2eeb..26f91e3e5 100644 --- a/apps/ui/sources/dev/testkit/rootLayoutTestkit.ts +++ b/apps/ui/sources/dev/testkit/rootLayoutTestkit.ts @@ -1,7 +1,11 @@ import type { FeaturesResponse as RootLayoutFeatures } from '@happier-dev/protocol'; type RootLayoutFeaturesOverrides = Omit<Partial<RootLayoutFeatures>, 'features' | 'capabilities'> & Readonly<{ - features?: Omit<Partial<RootLayoutFeatures['features']>, 'attachments' | 'automations' | 'connectedServices' | 'updates' | 'sharing' | 'voice' | 'social' | 'auth'> & Readonly<{ + features?: Omit< + Partial<RootLayoutFeatures['features']>, + 'attachments' | 'automations' | 'connectedServices' | 'updates' | 'sharing' | 'voice' | 'social' | 'auth' | 'encryption' + > & + Readonly<{ attachments?: Partial<RootLayoutFeatures['features']['attachments']>; automations?: Partial<RootLayoutFeatures['features']['automations']>; connectedServices?: Partial<RootLayoutFeatures['features']['connectedServices']>; @@ -10,17 +14,24 @@ type RootLayoutFeaturesOverrides = Omit<Partial<RootLayoutFeatures>, 'features' voice?: Partial<RootLayoutFeatures['features']['voice']>; social?: Partial<RootLayoutFeatures['features']['social']>; auth?: Partial<RootLayoutFeatures['features']['auth']>; + encryption?: Partial<RootLayoutFeatures['features']['encryption']>; }>; - capabilities?: Omit<Partial<RootLayoutFeatures['capabilities']>, 'oauth' | 'social' | 'auth'> & Readonly<{ + capabilities?: Omit<Partial<RootLayoutFeatures['capabilities']>, 'oauth' | 'social' | 'auth' | 'encryption'> & + Readonly<{ oauth?: Partial<RootLayoutFeatures['capabilities']['oauth']>; social?: Partial<RootLayoutFeatures['capabilities']['social']>; auth?: Partial<RootLayoutFeatures['capabilities']['auth']>; + encryption?: Partial<RootLayoutFeatures['capabilities']['encryption']>; }>; }>; const BASE_ROOT_LAYOUT_FEATURES: RootLayoutFeatures = { features: { bugReports: { enabled: true }, + encryption: { + plaintextStorage: { enabled: false }, + accountOptOut: { enabled: false }, + }, attachments: { uploads: { enabled: true }, }, @@ -50,6 +61,9 @@ const BASE_ROOT_LAYOUT_FEATURES: RootLayoutFeatures = { recovery: { providerReset: { enabled: false }, }, + login: { + keyChallenge: { enabled: true }, + }, ui: { recoveryKeyReminder: { enabled: true }, }, @@ -65,6 +79,11 @@ const BASE_ROOT_LAYOUT_FEATURES: RootLayoutFeatures = { contextWindowMs: 30 * 60 * 1_000, }, voice: { configured: false, provider: null, requested: false, disabledByBuildPolicy: false }, + encryption: { + storagePolicy: 'required_e2ee', + allowAccountOptOut: false, + defaultAccountMode: 'e2ee', + }, social: { friends: { allowUsername: false, @@ -74,7 +93,7 @@ const BASE_ROOT_LAYOUT_FEATURES: RootLayoutFeatures = { oauth: { providers: { github: { enabled: true, configured: true } } }, auth: { signup: { methods: [{ id: 'anonymous', enabled: true }] }, - login: { requiredProviders: [] }, + login: { methods: [{ id: 'key_challenge', enabled: true }], requiredProviders: [] }, recovery: { providerReset: { providers: [] }, }, @@ -103,6 +122,7 @@ export function createRootLayoutFeaturesResponse(overrides?: RootLayoutFeaturesO const nextSocial: Partial<RootLayoutFeatures['features']['social']> = nextFeatures.social ?? {}; const nextSharing: Partial<RootLayoutFeatures['features']['sharing']> = nextFeatures.sharing ?? {}; const nextAttachments: Partial<RootLayoutFeatures['features']['attachments']> = nextFeatures.attachments ?? {}; + const nextEncryption: Partial<RootLayoutFeatures['features']['encryption']> = nextFeatures.encryption ?? {}; const nextConnectedServices: Partial<RootLayoutFeatures['features']['connectedServices']> = nextFeatures.connectedServices ?? {}; const nextUpdates: Partial<RootLayoutFeatures['features']['updates']> = nextFeatures.updates ?? {}; @@ -111,6 +131,7 @@ export function createRootLayoutFeaturesResponse(overrides?: RootLayoutFeaturesO const nextCapabilitiesAuth: Partial<RootLayoutFeatures['capabilities']['auth']> = nextCapabilities.auth ?? {}; const nextCapabilitiesSocial: Partial<RootLayoutFeatures['capabilities']['social']> = nextCapabilities.social ?? {}; const nextCapabilitiesOauth: Partial<RootLayoutFeatures['capabilities']['oauth']> = nextCapabilities.oauth ?? {}; + const nextCapabilitiesEncryption: Partial<RootLayoutFeatures['capabilities']['encryption']> = nextCapabilities.encryption ?? {}; const nextCapabilitiesAuthRecovery: Partial<RootLayoutFeatures['capabilities']['auth']['recovery']> = nextCapabilitiesAuth.recovery ?? {}; const nextCapabilitiesAuthUi: Partial<RootLayoutFeatures['capabilities']['auth']['ui']> = @@ -119,6 +140,18 @@ export function createRootLayoutFeaturesResponse(overrides?: RootLayoutFeaturesO features: { ...BASE_ROOT_LAYOUT_FEATURES.features, ...nextFeatures, + encryption: { + ...BASE_ROOT_LAYOUT_FEATURES.features.encryption, + ...nextEncryption, + plaintextStorage: { + ...BASE_ROOT_LAYOUT_FEATURES.features.encryption.plaintextStorage, + ...(nextEncryption.plaintextStorage ?? {}), + }, + accountOptOut: { + ...BASE_ROOT_LAYOUT_FEATURES.features.encryption.accountOptOut, + ...(nextEncryption.accountOptOut ?? {}), + }, + }, attachments: { ...BASE_ROOT_LAYOUT_FEATURES.features.attachments, ...nextAttachments, @@ -166,6 +199,14 @@ export function createRootLayoutFeaturesResponse(overrides?: RootLayoutFeaturesO ...BASE_ROOT_LAYOUT_FEATURES.features.auth.recovery, ...(nextAuth.recovery ?? {}), }, + login: { + ...BASE_ROOT_LAYOUT_FEATURES.features.auth.login, + ...(nextAuth.login ?? {}), + keyChallenge: { + ...BASE_ROOT_LAYOUT_FEATURES.features.auth.login.keyChallenge, + ...(nextAuth.login?.keyChallenge ?? {}), + }, + }, ui: { ...BASE_ROOT_LAYOUT_FEATURES.features.auth.ui, ...(nextAuth.ui ?? {}), @@ -179,6 +220,10 @@ export function createRootLayoutFeaturesResponse(overrides?: RootLayoutFeaturesO ...BASE_ROOT_LAYOUT_FEATURES.capabilities.voice, ...(nextCapabilities.voice ?? {}), }, + encryption: { + ...BASE_ROOT_LAYOUT_FEATURES.capabilities.encryption, + ...nextCapabilitiesEncryption, + }, social: { ...BASE_ROOT_LAYOUT_FEATURES.capabilities.social, ...nextCapabilitiesSocial, diff --git a/apps/ui/sources/dev/vitestSetup.ts b/apps/ui/sources/dev/vitestSetup.ts index ee992baf5..c65845c82 100644 --- a/apps/ui/sources/dev/vitestSetup.ts +++ b/apps/ui/sources/dev/vitestSetup.ts @@ -136,6 +136,22 @@ vi.mock('@shopify/react-native-skia', () => ({ rrect: () => ({}), })); +// `react-native-typography` relies on React Native's platform resolution (e.g. systemWeights.web.js), +// which Node/Vitest cannot resolve via CJS `require("../helpers/systemWeights")`. Provide a minimal +// stub so components can render without pulling in platform-specific internals. +vi.mock('react-native-typography', () => ({ + iOSUIKit: { + title3: {}, + title3Object: {}, + }, + human: {}, + humanDense: {}, + humanTall: {}, + material: {}, + materialDense: {}, + materialTall: {}, +})); + // `expo-constants` reads React Native `NativeModules` and isn't safe to import in Vitest. vi.mock('expo-constants', () => ({ default: { @@ -168,26 +184,84 @@ vi.mock('expo-secure-store', () => ({ // `react-native-unistyles` requires a Babel plugin at runtime which isn't present in Vitest. // Provide a lightweight mock so view/components can render in tests. vi.mock('react-native-unistyles', () => { + // Keep this theme self-contained: many unit tests mock `react-native` and may omit Platform, + // so importing the real theme (which depends on Platform.select) would make those tests flaky. const theme = { + dark: false, colors: { - surface: '#fff', + // + // Main colors + // + text: '#000000', + textSecondary: '#666666', + textLink: '#2BACCC', + textDestructive: '#FF3B30', + warning: '#8E8E93', + success: '#34C759', + accent: { + blue: '#007AFF', + green: '#34C759', + orange: '#FF9500', + yellow: '#FFCC00', + red: '#FF3B30', + indigo: '#5856D6', + purple: '#AF52DE', + }, + surface: '#ffffff', + surfaceRipple: 'rgba(0, 0, 0, 0.08)', + surfacePressed: '#f0f0f2', surfaceSelected: '#f2f2f2', - divider: '#ddd', - text: '#000', - textSecondary: '#666', - groupped: { sectionTitle: '#666', background: '#fff' }, - header: { background: '#fff', tint: '#000' }, - button: { primary: { tint: '#000' } }, - shadow: { color: '#000', opacity: 0.2 }, - modal: { border: '#ddd' }, - switch: { track: { inactive: '#ccc', active: '#4ade80' }, thumb: { active: '#fff' } }, - input: { background: '#eee' }, - status: { error: '#ff3b30' }, - box: { error: { background: '#fee', border: '#f99', text: '#900' } }, + surfaceHigh: '#F8F8F8', + surfaceHighest: '#f0f0f0', + divider: '#eaeaea', + shadow: { color: '#000000', opacity: 0.1 }, + + // + // System components + // + groupped: { background: '#F5F5F5', chevron: '#C7C7CC', sectionTitle: '#8E8E93' }, + header: { background: '#ffffff', tint: '#18171C' }, + switch: { track: { inactive: '#dddddd', active: '#34C759' }, thumb: { active: '#FFFFFF', inactive: '#767577' } }, + radio: { active: '#007AFF', inactive: '#C0C0C0', dot: '#007AFF' }, + modal: { border: 'rgba(0, 0, 0, 0.1)' }, + button: { + primary: { background: '#000000', tint: '#FFFFFF', disabled: '#C0C0C0' }, + secondary: { tint: '#666666', surface: '#ffffff' }, + }, + input: { background: '#F5F5F5', text: '#000000', placeholder: '#999999' }, + + // + // Status / boxes + // + status: { error: '#ff3b30', connected: '#34C759', connecting: '#FFCC00', disconnected: '#999999', default: '#999999' }, + box: { + error: { background: '#fee', border: '#f99', text: '#900' }, + warning: { background: '#fff7e6', border: '#ffd591', text: '#ad6800' }, + }, + + // + // Permission buttons + // permissionButton: { - allow: { background: '#0f0' }, - deny: { background: '#f00' }, - allowAll: { background: '#00f' }, + allow: { background: '#34C759' }, + deny: { background: '#FF3B30' }, + allowAll: { background: '#007AFF' }, + inactive: { background: '#dddddd' }, + }, + + // + // Diff view palette (used by tool renderers) + // + diff: { + addedBg: '#e6ffed', + addedBorder: '#b7eb8f', + addedText: '#135200', + removedBg: '#ffecec', + removedBorder: '#ffa39e', + removedText: '#a8071a', + hunkHeaderBg: '#f5f5f5', + hunkHeaderText: '#666', + contextText: '#333', }, }, }; @@ -196,8 +270,13 @@ vi.mock('react-native-unistyles', () => { StyleSheet: { create: (styles: any) => (typeof styles === 'function' ? styles(theme) : styles), configure: () => {}, + absoluteFillObject: {}, }, useUnistyles: () => ({ theme }), - UnistylesRuntime: { setRootViewBackgroundColor: () => {} }, + UnistylesRuntime: { + setRootViewBackgroundColor: () => {}, + setAdaptiveThemes: () => {}, + setTheme: () => {}, + }, }; }); diff --git a/apps/ui/sources/encryption/aes.web.ts b/apps/ui/sources/encryption/aes.web.ts index 970e507fa..dfd649c81 100644 --- a/apps/ui/sources/encryption/aes.web.ts +++ b/apps/ui/sources/encryption/aes.web.ts @@ -5,13 +5,14 @@ import { decodeUTF8, encodeUTF8 } from './text'; const IV_LENGTH_BYTES = 12; async function importAesGcmKeyFromBase64(keyB64: string): Promise<CryptoKey> { - const keyBytes = decodeBase64(keyB64, 'base64'); + const keyBytes = new Uint8Array(decodeBase64(keyB64, 'base64')); return await globalThis.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); } async function encryptAesGcmBytes(plaintext: Uint8Array, key: CryptoKey): Promise<Uint8Array> { const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES)); - const ciphertext = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); + const plaintextBytes = new Uint8Array(plaintext); + const ciphertext = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintextBytes); const ciphertextBytes = new Uint8Array(ciphertext); const out = new Uint8Array(iv.length + ciphertextBytes.length); @@ -26,7 +27,8 @@ async function decryptAesGcmBytes(payload: Uint8Array, key: CryptoKey): Promise< } const iv = payload.slice(0, IV_LENGTH_BYTES); const ciphertextBytes = payload.slice(IV_LENGTH_BYTES); - const plaintext = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertextBytes); + const ciphertextPayloadBytes = new Uint8Array(ciphertextBytes); + const plaintext = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertextPayloadBytes); return new Uint8Array(plaintext); } @@ -40,7 +42,7 @@ export async function encryptAESGCMString(data: string, key64: string): Promise< export async function decryptAESGCMString(data: string, key64: string): Promise<string | null> { try { const key = await importAesGcmKeyFromBase64(key64); - const payloadBytes = decodeBase64(data, 'base64'); + const payloadBytes = new Uint8Array(decodeBase64(data, 'base64')); const plaintextBytes = await decryptAesGcmBytes(payloadBytes, key); return new TextDecoder().decode(plaintextBytes).trim(); } catch { @@ -57,4 +59,3 @@ export async function decryptAESGCM(data: Uint8Array, key64: string): Promise<Ui const raw = await decryptAESGCMString(encodeBase64(data, 'base64'), key64); return raw ? encodeUTF8(raw) : null; } - diff --git a/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx b/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx index 34115eee9..9b168e39a 100644 --- a/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx +++ b/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx @@ -122,14 +122,20 @@ describe('useConnectAccount (scanner lifecycle)', () => { const event = { data: 'happier:///account?abc123' }; // Fire twice before the approval promise resolves. - void onBarcodeScannedHandler!(event); - void onBarcodeScannedHandler!(event); - - // Allow microtasks to run. - await act(async () => {}); + let firstResult: unknown; + let secondResult: unknown; + await act(async () => { + firstResult = onBarcodeScannedHandler!(event); + secondResult = onBarcodeScannedHandler!(event); + }); expect(authAccountApproveSpy).toHaveBeenCalledTimes(1); - deferred.resolve(undefined); + await act(async () => { + deferred.resolve(undefined); + await Promise.resolve(); + await (firstResult instanceof Promise ? firstResult : Promise.resolve()); + await (secondResult instanceof Promise ? secondResult : Promise.resolve()); + }); }); }); diff --git a/apps/ui/sources/hooks/server/connectedServices/useConnectedServiceQuotaBadges.test.ts b/apps/ui/sources/hooks/server/connectedServices/useConnectedServiceQuotaBadges.test.ts index 8cd1f702b..1c0813679 100644 --- a/apps/ui/sources/hooks/server/connectedServices/useConnectedServiceQuotaBadges.test.ts +++ b/apps/ui/sources/hooks/server/connectedServices/useConnectedServiceQuotaBadges.test.ts @@ -32,6 +32,7 @@ vi.mock('@/auth/context/AuthContext', () => ({ vi.mock('@/sync/store/hooks', () => ({ useSettings: () => useSettingsSpy(), + useLocalSetting: () => 1, })); vi.mock('@/hooks/server/useFeatureEnabled', () => ({ diff --git a/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts b/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts index 936665eff..dc88b9b6b 100644 --- a/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts +++ b/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts @@ -50,6 +50,13 @@ export function buildServerFeaturesResponse(overrides: FixtureOverrides = {}): F return { features: { bugReports: { enabled: true }, + e2ee: { + keylessAccounts: { enabled: false }, + }, + encryption: { + plaintextStorage: { enabled: false }, + accountOptOut: { enabled: false }, + }, attachments: { uploads: { enabled: true }, }, @@ -86,6 +93,10 @@ export function buildServerFeaturesResponse(overrides: FixtureOverrides = {}): F recovery: { providerReset: { enabled: false }, }, + mtls: { enabled: false }, + login: { + keyChallenge: { enabled: true }, + }, ui: { recoveryKeyReminder: { enabled: true }, }, @@ -106,6 +117,11 @@ export function buildServerFeaturesResponse(overrides: FixtureOverrides = {}): F requested: voiceEnabled, disabledByBuildPolicy: false, }, + encryption: { + storagePolicy: 'required_e2ee', + allowAccountOptOut: false, + defaultAccountMode: 'e2ee', + }, social: { friends: { allowUsername: overrides.friendsAllowUsername ?? false, @@ -116,11 +132,22 @@ export function buildServerFeaturesResponse(overrides: FixtureOverrides = {}): F providers: oauthProviders, }, auth: { + methods: [], signup: { methods: [{ id: 'anonymous', enabled: true }] }, - login: { requiredProviders: [] }, + login: { methods: [{ id: 'key_challenge', enabled: true }], requiredProviders: [] }, recovery: { providerReset: { providers: [] }, }, + mtls: { + mode: 'forwarded', + autoProvision: false, + identitySource: 'san_email', + policy: { + trustForwardedHeaders: false, + issuerAllowlist: { enabled: false, count: 0 }, + emailDomainAllowlist: { enabled: false, count: 0 }, + }, + }, ui: { autoRedirect: { enabled: false, providerId: null }, }, diff --git a/apps/ui/sources/hooks/session/files/executeScmCommit.daemonUnavailable.test.ts b/apps/ui/sources/hooks/session/files/executeScmCommit.daemonUnavailable.test.ts new file mode 100644 index 000000000..9ba973fbb --- /dev/null +++ b/apps/ui/sources/hooks/session/files/executeScmCommit.daemonUnavailable.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SCM_OPERATION_ERROR_CODES } from '@happier-dev/protocol'; + +const modalAlert = vi.hoisted(() => vi.fn()); +const sessionScmCommitCreate = vi.hoisted(() => vi.fn()); +const withSessionProjectScmOperationLock = vi.hoisted(() => vi.fn(async (input: any) => { + await input.run(); + return { started: true, message: '' }; +})); + +vi.mock('@/modal', () => ({ + Modal: { + alert: modalAlert, + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/scm/operations/withOperationLock', () => ({ + withSessionProjectScmOperationLock, +})); + +vi.mock('@/sync/ops', () => ({ + sessionScmCommitCreate, +})); + +vi.mock('@/scm/scmStatusSync', () => ({ + scmStatusSync: { + invalidateFromMutationAndAwait: vi.fn(async () => {}), + }, +})); + +describe('executeScmCommit (daemon unavailable)', () => { + it('shows daemon-unavailable alert with Retry when commit RPC backend is unavailable', async () => { + modalAlert.mockReset(); + sessionScmCommitCreate.mockReset(); + + sessionScmCommitCreate.mockResolvedValueOnce({ + success: false, + errorCode: SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE, + error: 'RPC method not available', + }); + + const { executeScmCommit } = await import('./executeScmCommit'); + + await executeScmCommit({ + sessionId: 's1', + commitMessage: 'feat: test', + scmCommitStrategy: 'git_staging', + commitSelectionPaths: [], + commitSelectionPatches: [], + loadCommitHistory: vi.fn(async () => {}), + setScmOperationBusy: vi.fn(), + setScmOperationStatus: vi.fn(), + tracking: null, + }); + + expect(modalAlert).toHaveBeenCalled(); + const [title, message, buttons] = modalAlert.mock.calls[0] ?? []; + expect(title).toBe('errors.daemonUnavailableTitle'); + expect(String(message ?? '')).toContain('errors.daemonUnavailableBody'); + expect(Array.isArray(buttons)).toBe(true); + expect((buttons as any[]).some((b) => b?.text === 'common.retry')).toBe(true); + }); + + it('does not retry when caller indicates it is unmounted', async () => { + modalAlert.mockReset(); + sessionScmCommitCreate.mockReset(); + + sessionScmCommitCreate.mockResolvedValueOnce({ + success: false, + errorCode: SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE, + error: 'RPC method not available', + }); + + const { executeScmCommit } = await import('./executeScmCommit'); + + await executeScmCommit({ + sessionId: 's1', + commitMessage: 'feat: test', + scmCommitStrategy: 'git_staging', + commitSelectionPaths: [], + commitSelectionPatches: [], + loadCommitHistory: vi.fn(async () => {}), + setScmOperationBusy: vi.fn(), + setScmOperationStatus: vi.fn(), + tracking: null, + shouldContinue: () => false, + } as any); + + const [_title, _message, buttons] = modalAlert.mock.calls[0] ?? []; + const retry = (buttons as any[]).find((b) => b?.text === 'common.retry'); + expect(retry).toBeTruthy(); + + retry.onPress(); + await new Promise((r) => setTimeout(r, 0)); + + expect(sessionScmCommitCreate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/ui/sources/hooks/session/files/executeScmCommit.ts b/apps/ui/sources/hooks/session/files/executeScmCommit.ts index f9d40d088..b379603fe 100644 --- a/apps/ui/sources/hooks/session/files/executeScmCommit.ts +++ b/apps/ui/sources/hooks/session/files/executeScmCommit.ts @@ -13,6 +13,8 @@ import { reportSessionScmOperation, type ScmOperationTracker, trackBlockedScmOpe import { storage } from '@/sync/domains/state/storage'; import { sessionScmCommitCreate } from '@/sync/ops'; import { SCM_OPERATION_ERROR_CODES } from '@happier-dev/protocol'; +import { tryShowDaemonUnavailableAlertForRpcError } from '@/utils/errors/daemonUnavailableAlert'; +import { tryShowDaemonUnavailableAlertForScmOperationFailure } from '@/scm/operations/scmDaemonUnavailableAlert'; export async function executeScmCommit(input: { sessionId: string; @@ -24,6 +26,7 @@ export async function executeScmCommit(input: { setScmOperationBusy: (busy: boolean) => void; setScmOperationStatus: (status: string | null) => void; tracking: ScmOperationTracker | null; + shouldContinue?: () => boolean; }): Promise<void> { const lockResult = await withSessionProjectScmOperationLock({ state: storage.getState(), @@ -47,6 +50,15 @@ export async function executeScmCommit(input: { }); if (!response.success) { + const shownDaemonUnavailable = tryShowDaemonUnavailableAlertForScmOperationFailure({ + errorCode: response.errorCode, + onRetry: () => { + void executeScmCommit(input); + }, + shouldContinue: input.shouldContinue ?? null, + }); + if (shownDaemonUnavailable) return; + const errorMessage = buildScmCommitFailureMessage({ errorCode: response.errorCode, error: response.error, @@ -118,7 +130,16 @@ export async function executeScmCommit(input: { surface: 'files', tracking: input.tracking, }); - Modal.alert(t('common.error'), fallbackMessage); + const shownDaemonUnavailable = tryShowDaemonUnavailableAlertForRpcError({ + error, + onRetry: () => { + void executeScmCommit(input); + }, + shouldContinue: input.shouldContinue ?? null, + }); + if (!shownDaemonUnavailable) { + Modal.alert(t('common.error'), fallbackMessage); + } } finally { input.setScmOperationBusy(false); input.setScmOperationStatus(null); diff --git a/apps/ui/sources/hooks/session/files/useFileScmStageActions.daemonUnavailable.test.ts b/apps/ui/sources/hooks/session/files/useFileScmStageActions.daemonUnavailable.test.ts new file mode 100644 index 000000000..1811cb64f --- /dev/null +++ b/apps/ui/sources/hooks/session/files/useFileScmStageActions.daemonUnavailable.test.ts @@ -0,0 +1,127 @@ +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SCM_OPERATION_ERROR_CODES } from '@happier-dev/protocol'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const modalAlert = vi.hoisted(() => vi.fn()); +const sessionScmChangeInclude = vi.hoisted(() => vi.fn()); +const sessionScmChangeExclude = vi.hoisted(() => vi.fn()); +const withSessionProjectScmOperationLock = vi.hoisted(() => vi.fn(async (input: any) => { + await input.run(); + return { started: true, message: '' }; +})); + +vi.mock('@/modal', () => ({ + Modal: { + alert: modalAlert, + confirm: vi.fn(async () => true), + prompt: vi.fn(async () => null), + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/sync/ops', () => ({ + sessionScmChangeInclude, + sessionScmChangeExclude, +})); + +vi.mock('@/scm/scmPatchSelection', () => ({ + buildPatchFromSelectedDiffLines: () => 'patch', +})); + +vi.mock('@/scm/core/operationPolicy', () => ({ + evaluateScmOperationPreflight: () => ({ allowed: true, message: '' }), +})); + +vi.mock('@/scm/operations/withOperationLock', () => ({ + withSessionProjectScmOperationLock, +})); + +vi.mock('@/scm/scmStatusSync', () => ({ + scmStatusSync: { + invalidateFromMutationAndAwait: vi.fn(async () => {}), + }, +})); + +vi.mock('@/scm/operations/reporting', () => ({ + reportSessionScmOperation: () => {}, + trackBlockedScmOperation: () => {}, +})); + +vi.mock('@/track', () => ({ + tracking: null, +})); + +describe('useFileScmStageActions (daemon unavailable)', () => { + beforeEach(() => { + modalAlert.mockReset(); + sessionScmChangeInclude.mockReset(); + sessionScmChangeExclude.mockReset(); + withSessionProjectScmOperationLock.mockClear(); + }); + + it('shows daemon-unavailable alert when include/exclude RPC backend is unavailable', async () => { + sessionScmChangeInclude.mockResolvedValueOnce({ + success: false, + errorCode: SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE, + error: 'RPC method not available', + }); + + const { useFileScmStageActions } = await import('./useFileScmStageActions'); + + const props: Parameters<typeof useFileScmStageActions>[0] = { + sessionId: 's1', + sessionPath: '/tmp', + filePath: 'a.txt', + scmSnapshot: null, + scmWriteEnabled: true, + scmCommitStrategy: 'git_staging', + includeExcludeEnabled: true, + diffMode: 'pending', + diffContent: 'diff', + lineSelectionEnabled: true, + selectedLineIndexes: new Set([1]), + refreshAll: vi.fn(async () => {}), + setSelectedLineIndexes: vi.fn(), + }; + + let current: ReturnType<typeof useFileScmStageActions> | null = null; + let tree: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create( + React.createElement(() => { + current = useFileScmStageActions(props); + return React.createElement('View'); + }), + ); + }); + + await act(async () => { + await current!.applySelectedLines(); + }); + + expect(modalAlert).toHaveBeenCalled(); + const [title, message, buttons] = modalAlert.mock.calls[0] ?? []; + expect(title).toBe('errors.daemonUnavailableTitle'); + expect(String(message ?? '')).toContain('errors.daemonUnavailableBody'); + expect(Array.isArray(buttons)).toBe(true); + const retry = (buttons as any[]).find((b) => b?.text === 'common.retry'); + expect(retry).toBeTruthy(); + + act(() => { + tree.unmount(); + }); + + await act(async () => { + retry.onPress(); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(sessionScmChangeInclude).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/ui/sources/hooks/session/files/useFileScmStageActions.ts b/apps/ui/sources/hooks/session/files/useFileScmStageActions.ts index 5d1c82c27..0f5dc79b2 100644 --- a/apps/ui/sources/hooks/session/files/useFileScmStageActions.ts +++ b/apps/ui/sources/hooks/session/files/useFileScmStageActions.ts @@ -17,6 +17,8 @@ import { withSessionProjectScmOperationLock } from '@/scm/operations/withOperati import { reportSessionScmOperation, trackBlockedScmOperation } from '@/scm/operations/reporting'; import { tracking } from '@/track'; import { applyFileStageAction } from '@/scm/operations/applyFileStageAction'; +import { tryShowDaemonUnavailableAlertForScmOperationFailure } from '@/scm/operations/scmDaemonUnavailableAlert'; +import { useMountedRef } from '@/hooks/ui/useMountedRef'; type DiffMode = 'included' | 'pending' | 'both'; @@ -52,6 +54,12 @@ export function useFileScmStageActions(input: { } = input; const [isApplyingStage, setIsApplyingStage] = React.useState(false); + const mountedRef = useMountedRef(); + + const setIsApplyingStageSafe = React.useCallback((value: boolean) => { + if (!mountedRef.current) return; + setIsApplyingStage(value); + }, [mountedRef]); const handleStage = React.useCallback(async (stage: boolean) => { if (!sessionId) return; @@ -65,11 +73,12 @@ export function useFileScmStageActions(input: { commitStrategy: scmCommitStrategy, stage, surface: 'file', + shouldContinue: () => mountedRef.current, }); return; } - setIsApplyingStage(true); + setIsApplyingStageSafe(true); try { await applyFileStageAction({ sessionId, @@ -80,12 +89,16 @@ export function useFileScmStageActions(input: { commitStrategy: scmCommitStrategy, stage, surface: 'file', - refreshAll, + refreshAll: async () => { + if (!mountedRef.current) return; + await refreshAll(); + }, + shouldContinue: () => mountedRef.current, }); } finally { - setIsApplyingStage(false); + setIsApplyingStageSafe(false); } - }, [filePath, scmCommitStrategy, scmSnapshot, scmWriteEnabled, refreshAll, sessionId, sessionPath]); + }, [filePath, scmCommitStrategy, scmSnapshot, scmWriteEnabled, refreshAll, sessionId, sessionPath, mountedRef, setIsApplyingStageSafe]); const applySelectedLines = React.useCallback(async () => { if (!sessionId || !sessionPath || !diffContent) return; @@ -153,7 +166,7 @@ export function useFileScmStageActions(input: { sessionId, operation: stageSelected ? 'stage' : 'unstage', run: async () => { - setIsApplyingStage(true); + setIsApplyingStageSafe(true); try { const response = stageSelected ? await sessionScmChangeInclude(sessionId, { patch }) @@ -176,7 +189,16 @@ export function useFileScmStageActions(input: { surface: 'file', tracking, }); - Modal.alert(t('common.error'), errorMessage); + const shownDaemonUnavailable = tryShowDaemonUnavailableAlertForScmOperationFailure({ + errorCode: response.errorCode, + onRetry: () => { + void applySelectedLines(); + }, + shouldContinue: () => mountedRef.current, + }); + if (!shownDaemonUnavailable) { + Modal.alert(t('common.error'), errorMessage); + } return; } @@ -190,11 +212,15 @@ export function useFileScmStageActions(input: { surface: 'file', tracking, }); - setSelectedLineIndexes(new Set()); + if (mountedRef.current) { + setSelectedLineIndexes(new Set()); + } await scmStatusSync.invalidateFromMutationAndAwait(sessionId); - await refreshAll(); + if (mountedRef.current) { + await refreshAll(); + } } finally { - setIsApplyingStage(false); + setIsApplyingStageSafe(false); } }, }); @@ -222,6 +248,8 @@ export function useFileScmStageActions(input: { sessionId, sessionPath, setSelectedLineIndexes, + mountedRef, + setIsApplyingStageSafe, ]); return { diff --git a/apps/ui/sources/hooks/session/files/useFilesScmOperations.daemonUnavailable.test.ts b/apps/ui/sources/hooks/session/files/useFilesScmOperations.daemonUnavailable.test.ts new file mode 100644 index 000000000..ffc5b48b5 --- /dev/null +++ b/apps/ui/sources/hooks/session/files/useFilesScmOperations.daemonUnavailable.test.ts @@ -0,0 +1,185 @@ +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SCM_OPERATION_ERROR_CODES } from '@happier-dev/protocol'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const modalAlert = vi.hoisted(() => vi.fn()); +const modalConfirm = vi.hoisted(() => vi.fn(async () => true)); +const sessionScmRemoteFetch = vi.hoisted(() => vi.fn()); +const sessionScmRemotePull = vi.hoisted(() => vi.fn()); +const sessionScmRemotePush = vi.hoisted(() => vi.fn()); +const withSessionProjectScmOperationLock = vi.hoisted(() => vi.fn(async (input: any) => { + await input.run(); + return { started: true, message: '' }; +})); + +vi.mock('@/modal', () => ({ + Modal: { + alert: modalAlert, + confirm: modalConfirm, + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/sync/ops', () => ({ + sessionScmRemoteFetch, + sessionScmRemotePull, + sessionScmRemotePush, +})); + +vi.mock('@/scm/operations/withOperationLock', () => ({ + withSessionProjectScmOperationLock, +})); + +vi.mock('@/scm/core/operationPolicy', () => ({ + evaluateScmOperationPreflight: () => ({ allowed: true, message: '' }), +})); + +vi.mock('@/scm/operations/remoteTarget', () => ({ + inferRemoteTargetFromSnapshot: () => ({ remote: 'origin', branch: 'main' }), +})); + +vi.mock('@/scm/operations/remoteFeedback', () => ({ + buildRemoteConfirmDialog: () => ({ title: 'title', body: 'body', confirmText: 'ok', cancelText: 'cancel' }), + buildRemoteOperationBusyLabel: () => 'busy', + buildRemoteOperationSuccessDetail: () => 'success', + buildNonFastForwardFetchPromptDialog: () => ({ title: 't', body: 'b', confirmText: 'c', cancelText: 'x' }), +})); + +vi.mock('@/scm/operations/reporting', () => ({ + reportSessionScmOperation: () => {}, + trackBlockedScmOperation: () => {}, +})); + +vi.mock('@/track', () => ({ + tracking: null, +})); + +vi.mock('@/components/sessions/files/commit/showScmCommitMessageEditorModal', () => ({ + showScmCommitMessageEditorModal: vi.fn(async () => 'feat: commit'), +})); + +describe('useFilesScmOperations (daemon unavailable)', () => { + beforeEach(() => { + modalAlert.mockReset(); + modalConfirm.mockReset(); + sessionScmRemoteFetch.mockReset(); + sessionScmRemotePull.mockReset(); + sessionScmRemotePush.mockReset(); + withSessionProjectScmOperationLock.mockClear(); + }); + + it('shows daemon-unavailable alert with Retry when remote operation fails with RPC method-not-available', async () => { + sessionScmRemotePush.mockResolvedValueOnce({ + success: false, + errorCode: SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE, + error: 'RPC method not available', + }); + + const { useFilesScmOperations } = await import('./useFilesScmOperations'); + + const refreshScmData = vi.fn(async () => {}); + const loadCommitHistory = vi.fn(async () => {}); + + const mountHook = () => { + let current: ReturnType<typeof useFilesScmOperations> | null = null; + function Probe() { + current = useFilesScmOperations({ + sessionId: 's1', + sessionPath: '/tmp', + scmSnapshot: null, + scmWriteEnabled: true, + scmCommitStrategy: 'git_staging', + scmRemoteConfirmPolicy: 'never', + scmPushRejectPolicy: 'prompt_fetch', + refreshScmData, + loadCommitHistory, + }); + return React.createElement('View'); + } + + let tree: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create(React.createElement(Probe)); + }); + return { + tree: tree!, + getCurrent() { + if (!current) throw new Error('hook unavailable'); + return current; + }, + }; + }; + + const hook = mountHook(); + await act(async () => { + await hook.getCurrent().runRemoteOperation('push'); + }); + + expect(modalAlert).toHaveBeenCalled(); + const [title, message, buttons] = modalAlert.mock.calls[0] ?? []; + expect(title).toBe('errors.daemonUnavailableTitle'); + expect(String(message ?? '')).toContain('errors.daemonUnavailableBody'); + expect(Array.isArray(buttons)).toBe(true); + + act(() => { + hook.tree.unmount(); + }); + }); + + it('does not retry after unmount when pressing Retry', async () => { + sessionScmRemotePush.mockResolvedValueOnce({ + success: false, + errorCode: SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE, + error: 'RPC method not available', + }); + + const { useFilesScmOperations } = await import('./useFilesScmOperations'); + + const refreshScmData = vi.fn(async () => {}); + const loadCommitHistory = vi.fn(async () => {}); + + let current: ReturnType<typeof useFilesScmOperations> | null = null; + let tree: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create(React.createElement(() => { + current = useFilesScmOperations({ + sessionId: 's1', + sessionPath: '/tmp', + scmSnapshot: null, + scmWriteEnabled: true, + scmCommitStrategy: 'git_staging', + scmRemoteConfirmPolicy: 'never', + scmPushRejectPolicy: 'prompt_fetch', + refreshScmData, + loadCommitHistory, + }); + return React.createElement('View'); + })); + }); + + await act(async () => { + await current!.runRemoteOperation('push'); + }); + + const [_title, _message, buttons] = modalAlert.mock.calls[0] ?? []; + const retry = (buttons as any[]).find((b) => b?.text === 'common.retry'); + expect(retry).toBeTruthy(); + + act(() => { + tree!.unmount(); + }); + + await act(async () => { + retry.onPress(); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(sessionScmRemotePush).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/ui/sources/hooks/session/files/useFilesScmOperations.ts b/apps/ui/sources/hooks/session/files/useFilesScmOperations.ts index cad3fef0a..dcf1b693b 100644 --- a/apps/ui/sources/hooks/session/files/useFilesScmOperations.ts +++ b/apps/ui/sources/hooks/session/files/useFilesScmOperations.ts @@ -34,6 +34,8 @@ import { tracking } from '@/track'; import { SCM_OPERATION_ERROR_CODES } from '@happier-dev/protocol'; import { showScmCommitMessageEditorModal } from '@/components/sessions/files/commit/showScmCommitMessageEditorModal'; import { generateScmCommitMessage } from '@/scm/operations/commitMessageGenerator'; +import { tryShowDaemonUnavailableAlertForScmOperationFailure } from '@/scm/operations/scmDaemonUnavailableAlert'; +import { useMountedRef } from '@/hooks/ui/useMountedRef'; export function useFilesScmOperations(input: { sessionId: string; @@ -60,6 +62,16 @@ export function useFilesScmOperations(input: { const [scmOperationBusy, setScmOperationBusy] = React.useState(false); const [scmOperationStatus, setScmOperationStatus] = React.useState<string | null>(null); + const mountedRef = useMountedRef(); + + const setScmOperationBusySafe = React.useCallback((value: boolean) => { + if (!mountedRef.current) return; + setScmOperationBusy(value); + }, [mountedRef]); + const setScmOperationStatusSafe = React.useCallback((value: string | null) => { + if (!mountedRef.current) return; + setScmOperationStatus(value); + }, [mountedRef]); const commitSelectionPaths = useSessionProjectScmCommitSelectionPaths(sessionId); const commitSelectionPatches = useSessionProjectScmCommitSelectionPatches(sessionId); const scmCommitMessageGeneratorEnabled = useSetting('scmCommitMessageGeneratorEnabled'); @@ -158,8 +170,8 @@ export function useFilesScmOperations(input: { sessionId, operation: kind, run: async () => { - setScmOperationBusy(true); - setScmOperationStatus(buildRemoteOperationBusyLabel(kind, remoteTarget, t('files.detachedHead'))); + setScmOperationBusySafe(true); + setScmOperationStatusSafe(buildRemoteOperationBusyLabel(kind, remoteTarget, t('files.detachedHead'))); try { const response = kind === 'fetch' ? await sessionScmRemoteFetch(sessionId, { remote: remoteTarget.remote }) @@ -196,7 +208,16 @@ export function useFilesScmOperations(input: { surface: 'files', tracking, }); - Modal.alert(t('common.error'), message); + const shownDaemonUnavailable = tryShowDaemonUnavailableAlertForScmOperationFailure({ + errorCode: response.errorCode, + onRetry: () => { + void runRemoteOperation(kind); + }, + shouldContinue: () => mountedRef.current, + }); + if (!shownDaemonUnavailable) { + Modal.alert(t('common.error'), message); + } return; } @@ -214,16 +235,20 @@ export function useFilesScmOperations(input: { surface: 'files', tracking, }); - setScmOperationStatus('Refreshing repository status…'); + setScmOperationStatusSafe('Refreshing repository status…'); if (kind === 'pull' || kind === 'push') { await scmStatusSync.invalidateFromMutationAndAwait(sessionId); - await loadCommitHistory({ reset: true }); + if (mountedRef.current) { + await loadCommitHistory({ reset: true }); + } } else { - await refreshScmData(); + if (mountedRef.current) { + await refreshScmData(); + } } } finally { - setScmOperationBusy(false); - setScmOperationStatus(null); + setScmOperationBusySafe(false); + setScmOperationStatusSafe(null); } }, }); @@ -325,10 +350,14 @@ export function useFilesScmOperations(input: { scmCommitStrategy, commitSelectionPaths, commitSelectionPatches, - loadCommitHistory, - setScmOperationBusy, - setScmOperationStatus, + loadCommitHistory: async (opts?: { reset?: boolean }) => { + if (!mountedRef.current) return; + await loadCommitHistory(opts); + }, + setScmOperationBusy: setScmOperationBusySafe, + setScmOperationStatus: setScmOperationStatusSafe, tracking, + shouldContinue: () => mountedRef.current, }); }, [ scmCommitMessageGeneratorBackendId, @@ -343,6 +372,9 @@ export function useFilesScmOperations(input: { loadCommitHistory, sessionId, sessionPath, + mountedRef, + setScmOperationBusySafe, + setScmOperationStatusSafe, ]); return { diff --git a/apps/ui/sources/hooks/session/files/useFilesScmOperations.unsupportedNotDaemon.test.ts b/apps/ui/sources/hooks/session/files/useFilesScmOperations.unsupportedNotDaemon.test.ts new file mode 100644 index 000000000..4804489a3 --- /dev/null +++ b/apps/ui/sources/hooks/session/files/useFilesScmOperations.unsupportedNotDaemon.test.ts @@ -0,0 +1,115 @@ +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SCM_OPERATION_ERROR_CODES } from '@happier-dev/protocol'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const modalAlert = vi.hoisted(() => vi.fn()); +const sessionScmRemotePush = vi.hoisted(() => vi.fn()); +const withSessionProjectScmOperationLock = vi.hoisted(() => vi.fn(async (input: any) => { + await input.run(); + return { started: true, message: '' }; +})); + +vi.mock('@/modal', () => ({ + Modal: { + alert: modalAlert, + confirm: vi.fn(async () => true), + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/sync/ops', () => ({ + sessionScmRemoteFetch: vi.fn(), + sessionScmRemotePull: vi.fn(), + sessionScmRemotePush, +})); + +vi.mock('@/scm/operations/withOperationLock', () => ({ + withSessionProjectScmOperationLock, +})); + +vi.mock('@/scm/core/operationPolicy', () => ({ + evaluateScmOperationPreflight: () => ({ allowed: true, message: '' }), +})); + +vi.mock('@/scm/operations/remoteTarget', () => ({ + inferRemoteTargetFromSnapshot: () => ({ remote: 'origin', branch: 'main' }), +})); + +vi.mock('@/scm/operations/remoteFeedback', () => ({ + buildRemoteConfirmDialog: () => ({ title: 'title', body: 'body', confirmText: 'ok', cancelText: 'cancel' }), + buildRemoteOperationBusyLabel: () => 'busy', + buildRemoteOperationSuccessDetail: () => 'success', + buildNonFastForwardFetchPromptDialog: () => ({ title: 't', body: 'b', confirmText: 'c', cancelText: 'x' }), +})); + +vi.mock('@/scm/operations/reporting', () => ({ + reportSessionScmOperation: () => {}, + trackBlockedScmOperation: () => {}, +})); + +vi.mock('@/track', () => ({ + tracking: null, +})); + +vi.mock('@/components/sessions/files/commit/showScmCommitMessageEditorModal', () => ({ + showScmCommitMessageEditorModal: vi.fn(async () => 'feat: commit'), +})); + +describe('useFilesScmOperations (unsupported is not daemon unavailable)', () => { + beforeEach(() => { + modalAlert.mockReset(); + sessionScmRemotePush.mockReset(); + withSessionProjectScmOperationLock.mockClear(); + }); + + it('does not show daemon-unavailable alert for FEATURE_UNSUPPORTED even if error text is method-not-available', async () => { + sessionScmRemotePush.mockResolvedValueOnce({ + success: false, + errorCode: SCM_OPERATION_ERROR_CODES.FEATURE_UNSUPPORTED, + error: 'RPC method not available', + }); + + const { useFilesScmOperations } = await import('./useFilesScmOperations'); + + const refreshScmData = vi.fn(async () => {}); + const loadCommitHistory = vi.fn(async () => {}); + + let current: ReturnType<typeof useFilesScmOperations> | null = null; + let tree: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create(React.createElement(() => { + current = useFilesScmOperations({ + sessionId: 's1', + sessionPath: '/tmp', + scmSnapshot: null, + scmWriteEnabled: true, + scmCommitStrategy: 'git_staging', + scmRemoteConfirmPolicy: 'never', + scmPushRejectPolicy: 'prompt_fetch', + refreshScmData, + loadCommitHistory, + }); + return React.createElement('View'); + })); + }); + + await act(async () => { + await current!.runRemoteOperation('push'); + }); + + expect(modalAlert).toHaveBeenCalled(); + const [title] = modalAlert.mock.calls[0] ?? []; + expect(title).toBe('common.error'); + + act(() => { + tree.unmount(); + }); + }); +}); + diff --git a/apps/ui/sources/hooks/session/useConnectTerminal.authRedirect.test.tsx b/apps/ui/sources/hooks/session/useConnectTerminal.authRedirect.test.tsx index 23ce7fbef..57048d1be 100644 --- a/apps/ui/sources/hooks/session/useConnectTerminal.authRedirect.test.tsx +++ b/apps/ui/sources/hooks/session/useConnectTerminal.authRedirect.test.tsx @@ -90,11 +90,12 @@ vi.mock('@/sync/sync', () => ({ sync: { encryption: { contentDataKey: contentPublicKey, getContentPrivateKey: () => contentPrivateKey } }, })); -vi.mock('@/sync/domains/state/storageStore', () => ({ - storage: { +vi.mock('@/sync/domains/state/storageStore', () => { + const storage = { getState: () => ({ settings: { terminalConnectLegacySecretExportEnabled: false } }), - }, -})); + }; + return { storage, getStorage: () => storage }; +}); function buildTerminalConnectUrl(params: Readonly<{ terminalPublicKey: Uint8Array; serverUrl?: string }>): string { const publicKeyB64Url = Buffer.from(params.terminalPublicKey).toString('base64url'); diff --git a/apps/ui/sources/hooks/session/useConnectTerminal.scannerLifecycle.test.tsx b/apps/ui/sources/hooks/session/useConnectTerminal.scannerLifecycle.test.tsx index 6e8b43bf5..fbd1a0b1a 100644 --- a/apps/ui/sources/hooks/session/useConnectTerminal.scannerLifecycle.test.tsx +++ b/apps/ui/sources/hooks/session/useConnectTerminal.scannerLifecycle.test.tsx @@ -94,11 +94,12 @@ vi.mock('@/auth/terminal/terminalProvisioning', () => ({ buildTerminalResponseV2: vi.fn(() => new Uint8Array([1, 2, 3])), })); -vi.mock('@/sync/domains/state/storageStore', () => ({ - storage: { +vi.mock('@/sync/domains/state/storageStore', () => { + const storage = { getState: () => ({ settings: { terminalConnectLegacySecretExportEnabled: false } }), - }, -})); + }; + return { storage, getStorage: () => storage }; +}); describe('useConnectTerminal (scanner lifecycle)', () => { beforeEach(() => { diff --git a/apps/ui/sources/hooks/ui/useHappyAction.daemonUnavailable.test.tsx b/apps/ui/sources/hooks/ui/useHappyAction.daemonUnavailable.test.tsx new file mode 100644 index 000000000..01a42a2ec --- /dev/null +++ b/apps/ui/sources/hooks/ui/useHappyAction.daemonUnavailable.test.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import { RPC_ERROR_CODES } from '@happier-dev/protocol/rpc'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const modalAlertSpy = vi.hoisted(() => vi.fn((..._args: unknown[]) => {})); + +vi.mock('@/modal', () => ({ + Modal: { + alert: modalAlertSpy, + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +describe('useHappyAction (daemon unavailable)', () => { + it('shows a daemon-unavailable alert with Retry when action throws RPC method-not-available', async () => { + vi.resetModules(); + modalAlertSpy.mockClear(); + + const action = vi + .fn<() => Promise<void>>() + .mockRejectedValueOnce(Object.assign(new Error('RPC method not available'), { rpcErrorCode: RPC_ERROR_CODES.METHOD_NOT_AVAILABLE })) + .mockResolvedValueOnce(undefined); + + const { useHappyAction } = await import('./useHappyAction'); + + let doAction: null | (() => void) = null; + function Test() { + const [_loading, run] = useHappyAction(action); + doAction = run; + return null; + } + + await act(async () => { + renderer.create(React.createElement(Test)); + }); + if (!doAction) throw new Error('expected doAction to be set'); + + await act(async () => { + doAction!(); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(modalAlertSpy).toHaveBeenCalled(); + const [title, message, buttons] = modalAlertSpy.mock.calls[0] ?? []; + expect(title).toBe('errors.daemonUnavailableTitle'); + expect(String(message)).toContain('errors.daemonUnavailableBody'); + expect(Array.isArray(buttons)).toBe(true); + const retry = (buttons as any[]).find((b) => b?.text === 'common.retry'); + expect(retry).toBeTruthy(); + + await act(async () => { + retry.onPress(); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(action).toHaveBeenCalledTimes(2); + expect(modalAlertSpy).toHaveBeenCalledTimes(1); + }); + + it('does not retry after unmount (retry handler is mounted-safe)', async () => { + vi.resetModules(); + modalAlertSpy.mockClear(); + + const action = vi + .fn<() => Promise<void>>() + .mockRejectedValueOnce(Object.assign(new Error('RPC method not available'), { rpcErrorCode: RPC_ERROR_CODES.METHOD_NOT_AVAILABLE })) + .mockResolvedValueOnce(undefined); + + const { useHappyAction } = await import('./useHappyAction'); + + let doAction: null | (() => void) = null; + function Test() { + const [_loading, run] = useHappyAction(action); + doAction = run; + return null; + } + + let tree: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create(React.createElement(Test)); + }); + if (!doAction) throw new Error('expected doAction to be set'); + + await act(async () => { + doAction!(); + await new Promise((r) => setTimeout(r, 0)); + }); + + const [_title, _message, buttons] = modalAlertSpy.mock.calls[0] ?? []; + const retry = (buttons as any[]).find((b) => b?.text === 'common.retry'); + expect(retry).toBeTruthy(); + + act(() => { + tree!.unmount(); + }); + + await act(async () => { + retry.onPress(); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(action).toHaveBeenCalledTimes(1); + }); + + it('falls back to unknown error for non-RPC errors', async () => { + vi.resetModules(); + modalAlertSpy.mockClear(); + + const action = vi.fn<() => Promise<void>>().mockRejectedValueOnce(new Error('boom')); + const { useHappyAction } = await import('./useHappyAction'); + + let doAction: null | (() => void) = null; + function Test() { + const [_loading, run] = useHappyAction(action); + doAction = run; + return null; + } + + await act(async () => { + renderer.create(React.createElement(Test)); + }); + if (!doAction) throw new Error('expected doAction to be set'); + + await act(async () => { + doAction!(); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(modalAlertSpy).toHaveBeenCalledWith( + 'common.error', + 'errors.unknownError', + expect.any(Array), + ); + }); +}); diff --git a/apps/ui/sources/hooks/ui/useHappyAction.ts b/apps/ui/sources/hooks/ui/useHappyAction.ts index 303ebc6a8..ac8f16d43 100644 --- a/apps/ui/sources/hooks/ui/useHappyAction.ts +++ b/apps/ui/sources/hooks/ui/useHappyAction.ts @@ -2,16 +2,25 @@ import * as React from 'react'; import { Modal } from '@/modal'; import { t } from '@/text'; import { HappyError } from '@/utils/errors/errors'; +import { tryShowDaemonUnavailableAlertForRpcError } from '@/utils/errors/daemonUnavailableAlert'; +import { useMountedRef } from '@/hooks/ui/useMountedRef'; export function useHappyAction(action: () => Promise<void>) { const [loading, setLoading] = React.useState(false); const loadingRef = React.useRef(false); + const mountedRef = useMountedRef(); + + const setLoadingSafe = React.useCallback((value: boolean) => { + if (!mountedRef.current) return; + setLoading(value); + }, [mountedRef]); + const doAction = React.useCallback(() => { if (loadingRef.current) { return; } loadingRef.current = true; - setLoading(true); + setLoadingSafe(true); (async () => { try { while (true) { @@ -31,16 +40,27 @@ export function useHappyAction(action: () => Promise<void>) { Modal.alert(t('common.error'), e.message, [{ text: t('common.ok'), style: 'cancel' }]); break; } else { - Modal.alert(t('common.error'), t('errors.unknownError'), [{ text: t('common.ok'), style: 'cancel' }]); + const shown = tryShowDaemonUnavailableAlertForRpcError({ + error: e, + onRetry: () => { + void doAction(); + }, + shouldContinue: () => mountedRef.current, + titleKey: 'errors.daemonUnavailableTitle', + bodyKey: 'errors.daemonUnavailableBody', + }); + if (!shown) { + Modal.alert(t('common.error'), t('errors.unknownError'), [{ text: t('common.ok'), style: 'cancel' }]); + } break; } } } } finally { loadingRef.current = false; - setLoading(false); + setLoadingSafe(false); } })(); - }, [action]); + }, [action, mountedRef, setLoadingSafe]); return [loading, doAction] as const; } diff --git a/apps/ui/sources/hooks/ui/useMountedRef.ts b/apps/ui/sources/hooks/ui/useMountedRef.ts new file mode 100644 index 000000000..7867c47f7 --- /dev/null +++ b/apps/ui/sources/hooks/ui/useMountedRef.ts @@ -0,0 +1,14 @@ +import * as React from 'react'; + +export function useMountedRef(): React.MutableRefObject<boolean> { + const mountedRef = React.useRef(false); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + return mountedRef; +} diff --git a/apps/ui/sources/hooks/ui/useMountedShouldContinue.test.ts b/apps/ui/sources/hooks/ui/useMountedShouldContinue.test.ts new file mode 100644 index 000000000..adb9504b2 --- /dev/null +++ b/apps/ui/sources/hooks/ui/useMountedShouldContinue.test.ts @@ -0,0 +1,34 @@ +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { describe, expect, it } from 'vitest'; + +import { useMountedShouldContinue } from './useMountedShouldContinue'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe('useMountedShouldContinue', () => { + it('returns true while mounted and false after unmount', async () => { + let shouldContinue: (() => boolean) | null = null; + let root: renderer.ReactTestRenderer | null = null; + + function Test() { + shouldContinue = useMountedShouldContinue(); + return null; + } + + await act(async () => { + root = renderer.create(React.createElement(Test)); + await Promise.resolve(); + }); + + expect(shouldContinue?.()).toBe(true); + + await act(async () => { + root?.unmount(); + await Promise.resolve(); + }); + + expect(shouldContinue?.()).toBe(false); + }); +}); + diff --git a/apps/ui/sources/hooks/ui/useMountedShouldContinue.ts b/apps/ui/sources/hooks/ui/useMountedShouldContinue.ts new file mode 100644 index 000000000..f4e0dac5a --- /dev/null +++ b/apps/ui/sources/hooks/ui/useMountedShouldContinue.ts @@ -0,0 +1,9 @@ +import * as React from 'react'; + +import { useMountedRef } from '@/hooks/ui/useMountedRef'; + +export function useMountedShouldContinue(): () => boolean { + const mountedRef = useMountedRef(); + return React.useCallback(() => mountedRef.current, [mountedRef]); +} + diff --git a/apps/ui/sources/modal/components/WebAlertModal.test.tsx b/apps/ui/sources/modal/components/WebAlertModal.test.tsx index 796066115..3624e0ae3 100644 --- a/apps/ui/sources/modal/components/WebAlertModal.test.tsx +++ b/apps/ui/sources/modal/components/WebAlertModal.test.tsx @@ -8,20 +8,18 @@ vi.mock('./BaseModal', () => ({ BaseModal: ({ children }: any) => React.createElement('BaseModal', null, children), })); -vi.mock('react-native', () => { - const React = require('react'); +vi.mock('react-native', async () => { + const rn = await import('@/dev/reactNativeStub'); return { + ...rn, + AppState: rn.AppState, View: (props: any) => React.createElement('View', props, props.children), Text: (props: any) => React.createElement('Text', props, props.children), Pressable: (props: any) => React.createElement('Pressable', props, props.children), + Platform: { ...rn.Platform, OS: 'web', select: (v: any) => v.web ?? v.default ?? null }, }; }); -vi.mock('react-native-unistyles', () => ({ - StyleSheet: { create: (styles: any) => styles }, - useUnistyles: () => {}, -})); - vi.mock('@/constants/Typography', () => ({ Typography: { default: () => ({}) }, })); diff --git a/apps/ui/sources/modal/components/WebAlertModal.tsx b/apps/ui/sources/modal/components/WebAlertModal.tsx index ae750f72b..b4e95bd26 100644 --- a/apps/ui/sources/modal/components/WebAlertModal.tsx +++ b/apps/ui/sources/modal/components/WebAlertModal.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { View, Text, Pressable } from 'react-native'; +import { View, Pressable } from 'react-native'; import { BaseModal } from './BaseModal'; import { AlertModalConfig, ConfirmModalConfig } from '../types'; import { Typography } from '@/constants/Typography'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; +import { Text } from '@/components/ui/text/Text'; + interface WebAlertModalProps { config: AlertModalConfig | ConfirmModalConfig; @@ -61,6 +63,7 @@ const stylesheet = StyleSheet.create((theme) => ({ button: { flex: 1, paddingVertical: 11, + paddingHorizontal: 14, alignItems: 'center', justifyContent: 'center', }, @@ -78,6 +81,10 @@ const stylesheet = StyleSheet.create((theme) => ({ buttonText: { fontSize: 17, color: theme.colors.textLink, + textAlign: 'center', + lineHeight: 20, + flexShrink: 1, + paddingHorizontal: 2, }, primaryText: { color: theme.colors.text, diff --git a/apps/ui/sources/modal/components/WebPromptModal.test.tsx b/apps/ui/sources/modal/components/WebPromptModal.test.tsx index 49d848a62..c6d30fcb8 100644 --- a/apps/ui/sources/modal/components/WebPromptModal.test.tsx +++ b/apps/ui/sources/modal/components/WebPromptModal.test.tsx @@ -15,7 +15,8 @@ vi.mock('react-native', () => { Text: (props: any) => React.createElement('Text', props, props.children), TextInput: (props: any) => React.createElement('TextInput', props, props.children), Pressable: (props: any) => React.createElement('Pressable', props, props.children), - Platform: { OS: 'web' }, + Platform: { OS: 'web', select: (values: any) => values?.default ?? values?.web ?? values?.ios ?? values?.android }, + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, }; }); diff --git a/apps/ui/sources/modal/components/WebPromptModal.tsx b/apps/ui/sources/modal/components/WebPromptModal.tsx index aa05fee5b..dfca47500 100644 --- a/apps/ui/sources/modal/components/WebPromptModal.tsx +++ b/apps/ui/sources/modal/components/WebPromptModal.tsx @@ -1,9 +1,11 @@ import React, { useState, useRef, useEffect } from 'react'; -import { View, Text, TextInput, Pressable, KeyboardTypeOptions, Platform } from 'react-native'; +import { View, Pressable, KeyboardTypeOptions, Platform } from 'react-native'; import { BaseModal } from './BaseModal'; import { PromptModalConfig } from '../types'; import { Typography } from '@/constants/Typography'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Text, TextInput } from '@/components/ui/text/Text'; + interface WebPromptModalProps { config: PromptModalConfig; @@ -16,7 +18,7 @@ interface WebPromptModalProps { export function WebPromptModal({ config, onClose, onConfirm, showBackdrop = true, zIndexBase }: WebPromptModalProps) { const { theme } = useUnistyles(); const [inputValue, setInputValue] = useState(config.defaultValue || ''); - const inputRef = useRef<TextInput>(null); + const inputRef = useRef<React.ElementRef<typeof TextInput> | null>(null); useEffect(() => { // Auto-focus the input when modal opens diff --git a/apps/ui/sources/scm/operations/applyFileStageAction.daemonUnavailable.test.ts b/apps/ui/sources/scm/operations/applyFileStageAction.daemonUnavailable.test.ts new file mode 100644 index 000000000..b2f7d7eb6 --- /dev/null +++ b/apps/ui/sources/scm/operations/applyFileStageAction.daemonUnavailable.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SCM_OPERATION_ERROR_CODES } from '@happier-dev/protocol'; + +const modalAlert = vi.hoisted(() => vi.fn()); +const sessionScmChangeInclude = vi.hoisted(() => vi.fn()); +const sessionScmChangeExclude = vi.hoisted(() => vi.fn()); +const withSessionProjectScmOperationLock = vi.hoisted(() => vi.fn(async (input: any) => { + await input.run(); + return { started: true, message: '' }; +})); +const evaluateScmOperationPreflight = vi.hoisted(() => vi.fn(() => ({ allowed: true, message: '' }))); + +vi.mock('@/modal', () => ({ + Modal: { + alert: modalAlert, + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/sync/ops', () => ({ + sessionScmChangeInclude, + sessionScmChangeExclude, +})); + +vi.mock('@/scm/operations/withOperationLock', () => ({ + withSessionProjectScmOperationLock, +})); + +vi.mock('@/scm/core/operationPolicy', () => ({ + evaluateScmOperationPreflight, +})); + +vi.mock('@/scm/scmStatusSync', () => ({ + scmStatusSync: { + invalidateFromMutationAndAwait: vi.fn(async () => {}), + }, +})); + +describe('applyFileStageAction (daemon unavailable)', () => { + it('shows daemon-unavailable alert with Retry when SCM RPC backend is unavailable', async () => { + modalAlert.mockReset(); + sessionScmChangeInclude.mockReset(); + sessionScmChangeExclude.mockReset(); + + sessionScmChangeInclude.mockResolvedValueOnce({ + success: false, + errorCode: SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE, + error: 'RPC method not available', + }); + + const { applyFileStageAction } = await import('./applyFileStageAction'); + + await applyFileStageAction({ + sessionId: 's1', + sessionPath: '/tmp', + filePath: 'a.txt', + snapshot: null, + scmWriteEnabled: true, + commitStrategy: 'git_staging', + stage: true, + surface: 'file', + }); + + expect(modalAlert).toHaveBeenCalled(); + const [title, message, buttons] = modalAlert.mock.calls[0] ?? []; + expect(title).toBe('errors.daemonUnavailableTitle'); + expect(String(message ?? '')).toContain('errors.daemonUnavailableBody'); + expect(Array.isArray(buttons)).toBe(true); + expect((buttons as any[]).some((b) => b?.text === 'common.retry')).toBe(true); + }); + + it('does not retry when caller indicates it is unmounted', async () => { + modalAlert.mockReset(); + sessionScmChangeInclude.mockReset(); + + sessionScmChangeInclude.mockResolvedValueOnce({ + success: false, + errorCode: SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE, + error: 'RPC method not available', + }); + + const { applyFileStageAction } = await import('./applyFileStageAction'); + + await applyFileStageAction({ + sessionId: 's1', + sessionPath: '/tmp', + filePath: 'a.txt', + snapshot: null, + scmWriteEnabled: true, + commitStrategy: 'git_staging', + stage: true, + surface: 'file', + shouldContinue: () => false, + } as any); + + expect(sessionScmChangeInclude).toHaveBeenCalledTimes(1); + const [_title, _message, buttons] = modalAlert.mock.calls[0] ?? []; + const retry = (buttons as any[]).find((b) => b?.text === 'common.retry'); + expect(retry).toBeTruthy(); + + retry.onPress(); + await new Promise((r) => setTimeout(r, 0)); + + expect(sessionScmChangeInclude).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/ui/sources/scm/operations/applyFileStageAction.ts b/apps/ui/sources/scm/operations/applyFileStageAction.ts index 571a21910..a984af1f7 100644 --- a/apps/ui/sources/scm/operations/applyFileStageAction.ts +++ b/apps/ui/sources/scm/operations/applyFileStageAction.ts @@ -13,6 +13,7 @@ import { getScmUserFacingError } from '@/scm/operations/userFacingErrors'; import { withSessionProjectScmOperationLock } from '@/scm/operations/withOperationLock'; import { reportSessionScmOperation, trackBlockedScmOperation } from '@/scm/operations/reporting'; import { tracking } from '@/track'; +import { tryShowDaemonUnavailableAlertForScmOperationFailure } from '@/scm/operations/scmDaemonUnavailableAlert'; export async function applyFileStageAction(input: Readonly<{ sessionId: string; @@ -24,6 +25,7 @@ export async function applyFileStageAction(input: Readonly<{ stage: boolean; surface: 'file' | 'files'; refreshAll?: () => Promise<void>; + shouldContinue?: () => boolean; }>): Promise<void> { const { sessionId, @@ -98,6 +100,15 @@ export async function applyFileStageAction(input: Readonly<{ : await sessionScmChangeExclude(sessionId, { paths: [filePath] }); if (!response.success) { + const shownDaemonUnavailable = tryShowDaemonUnavailableAlertForScmOperationFailure({ + errorCode: response.errorCode, + onRetry: () => { + void applyFileStageAction(input); + }, + shouldContinue: input.shouldContinue ?? null, + }); + if (shownDaemonUnavailable) return; + const errorMessage = getScmUserFacingError({ errorCode: response.errorCode, error: response.error, @@ -147,4 +158,3 @@ export async function applyFileStageAction(input: Readonly<{ Modal.alert(t('common.error'), lockResult.message); } } - diff --git a/apps/ui/sources/scm/operations/scmDaemonUnavailableAlert.test.ts b/apps/ui/sources/scm/operations/scmDaemonUnavailableAlert.test.ts new file mode 100644 index 000000000..fafde3534 --- /dev/null +++ b/apps/ui/sources/scm/operations/scmDaemonUnavailableAlert.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SCM_OPERATION_ERROR_CODES } from '@happier-dev/protocol'; + +const showDaemonUnavailableAlert = vi.hoisted(() => vi.fn()); + +vi.mock('@/utils/errors/daemonUnavailableAlert', () => ({ + showDaemonUnavailableAlert, +})); + +describe('tryShowDaemonUnavailableAlertForScmOperationFailure', () => { + it('returns true and shows alert only for BACKEND_UNAVAILABLE', async () => { + showDaemonUnavailableAlert.mockReset(); + const { tryShowDaemonUnavailableAlertForScmOperationFailure } = await import('./scmDaemonUnavailableAlert'); + + const onRetry = vi.fn(); + const shouldContinue = () => true; + const shown = tryShowDaemonUnavailableAlertForScmOperationFailure({ + errorCode: SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE, + onRetry, + shouldContinue, + }); + + expect(shown).toBe(true); + expect(showDaemonUnavailableAlert).toHaveBeenCalledWith(expect.objectContaining({ + titleKey: 'errors.daemonUnavailableTitle', + bodyKey: 'errors.daemonUnavailableBody', + onRetry, + shouldContinue, + })); + }); + + it('returns false for FEATURE_UNSUPPORTED (even though message text may be reused elsewhere)', async () => { + showDaemonUnavailableAlert.mockReset(); + const { tryShowDaemonUnavailableAlertForScmOperationFailure } = await import('./scmDaemonUnavailableAlert'); + + const shown = tryShowDaemonUnavailableAlertForScmOperationFailure({ + errorCode: SCM_OPERATION_ERROR_CODES.FEATURE_UNSUPPORTED, + onRetry: vi.fn(), + }); + + expect(shown).toBe(false); + expect(showDaemonUnavailableAlert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ui/sources/scm/operations/scmDaemonUnavailableAlert.ts b/apps/ui/sources/scm/operations/scmDaemonUnavailableAlert.ts new file mode 100644 index 000000000..cb2a8c91b --- /dev/null +++ b/apps/ui/sources/scm/operations/scmDaemonUnavailableAlert.ts @@ -0,0 +1,22 @@ +import type { ScmOperationErrorCode } from '@happier-dev/protocol'; +import { SCM_OPERATION_ERROR_CODES } from '@happier-dev/protocol'; + +import { showDaemonUnavailableAlert } from '@/utils/errors/daemonUnavailableAlert'; + +export function tryShowDaemonUnavailableAlertForScmOperationFailure(params: Readonly<{ + errorCode?: ScmOperationErrorCode | string | null; + onRetry?: (() => void) | null; + shouldContinue?: (() => boolean) | null; +}>): boolean { + if (params.errorCode !== SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE) { + return false; + } + + showDaemonUnavailableAlert({ + titleKey: 'errors.daemonUnavailableTitle', + bodyKey: 'errors.daemonUnavailableBody', + onRetry: params.onRetry ?? null, + shouldContinue: params.shouldContinue ?? null, + }); + return true; +} diff --git a/apps/ui/sources/sync/api/account/apiAccountEncryptionMode.ts b/apps/ui/sources/sync/api/account/apiAccountEncryptionMode.ts new file mode 100644 index 000000000..2aaa1cdbd --- /dev/null +++ b/apps/ui/sources/sync/api/account/apiAccountEncryptionMode.ts @@ -0,0 +1,79 @@ +import type { AuthCredentials } from '@/auth/storage/tokenStorage'; +import { backoff } from '@/utils/timing/time'; +import { serverFetch } from '@/sync/http/client'; +import { HappyError } from '@/utils/errors/errors'; +import { + AccountEncryptionModeResponseSchema, + type AccountEncryptionModeResponse, +} from '@happier-dev/protocol'; + +type AccountEncryptionMode = AccountEncryptionModeResponse['mode']; + +export async function fetchAccountEncryptionMode( + credentials: AuthCredentials, +): Promise<{ mode: AccountEncryptionMode; updatedAt: number }> { + return await backoff(async () => { + const response = await serverFetch( + '/v1/account/encryption', + { + method: 'GET', + headers: { + Authorization: `Bearer ${credentials.token}`, + 'Content-Type': 'application/json', + }, + }, + { includeAuth: false }, + ); + + if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + throw new HappyError('Failed to load encryption setting', false, { status: response.status, kind: 'server' }); + } + throw new Error(`Failed to load account encryption mode: ${response.status}`); + } + + const data: unknown = await response.json(); + const parsed = AccountEncryptionModeResponseSchema.safeParse(data); + if (!parsed.success) { + throw new Error('Failed to parse account encryption mode response'); + } + return parsed.data; + }); +} + +export async function updateAccountEncryptionMode( + credentials: AuthCredentials, + mode: AccountEncryptionMode, +): Promise<{ mode: AccountEncryptionMode; updatedAt: number }> { + return await backoff(async () => { + const response = await serverFetch( + '/v1/account/encryption', + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${credentials.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ mode }), + }, + { includeAuth: false }, + ); + + if (!response.ok) { + if (response.status === 404) { + throw new HappyError('Encryption opt-out is not enabled on this server', false, { status: response.status, kind: 'config' }); + } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + throw new HappyError('Failed to update encryption setting', false, { status: response.status, kind: 'server' }); + } + throw new Error(`Failed to update account encryption mode: ${response.status}`); + } + + const data: unknown = await response.json(); + const parsed = AccountEncryptionModeResponseSchema.safeParse(data); + if (!parsed.success) { + throw new Error('Failed to parse account encryption mode response'); + } + return parsed.data; + }); +} diff --git a/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts b/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts index 62a31e861..b52e82c57 100644 --- a/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts +++ b/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts @@ -95,6 +95,30 @@ describe('serverFeaturesClient', () => { } }); + it('treats a 200 non-JSON features response as invalid_payload (not a network error)', async () => { + const htmlResponse = { + ok: true, + status: 200, + headers: { + get: (name: string) => (name.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null), + }, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON at position 0'); + }, + } as unknown as Response; + + (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(htmlResponse); + + const { getServerFeaturesSnapshot, resetServerFeaturesClientForTests } = await import('./serverFeaturesClient'); + resetServerFeaturesClientForTests(); + + const result = await getServerFeaturesSnapshot({ force: true, timeoutMs: 50 }); + expect(result.status).toBe('unsupported'); + if (result.status === 'unsupported') { + expect(result.reason).toBe('invalid_payload'); + } + }); + it('caches endpoint-missing responses even when forced (cooldown)', async () => { const payload = { features: { diff --git a/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.ts b/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.ts index 71dac04e3..3326fd57b 100644 --- a/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.ts +++ b/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.ts @@ -137,19 +137,27 @@ export async function getServerFeaturesSnapshot(params?: { const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const response = isExplicitServerRequest - ? await runtimeFetch(joinBaseAndPath(explicitServerUrl!, '/v1/features'), { - method: 'GET', - signal: controller.signal, - }) - : await serverFetch( - '/v1/features', - { + let response: Response; + try { + response = isExplicitServerRequest + ? await runtimeFetch(joinBaseAndPath(explicitServerUrl!, '/v1/features'), { method: 'GET', signal: controller.signal, - }, - { includeAuth: false }, - ); + }) + : await serverFetch( + '/v1/features', + { + method: 'GET', + signal: controller.signal, + }, + { includeAuth: false }, + ); + } catch (error) { + const aborted = controller.signal.aborted || (error instanceof Error && error.name === 'AbortError'); + const value: ServerFeaturesSnapshot = { status: 'error', reason: aborted ? 'timeout' : 'network' }; + cache.setSuccess(cacheKey, value, { ttlMs: getCacheTtlMs(value) }); + return value; + } if (!response.ok) { const value: ServerFeaturesSnapshot = isEndpointMissing(response.status) @@ -159,7 +167,22 @@ export async function getServerFeaturesSnapshot(params?: { return value; } - const payload = await response.json(); + const contentType = String(response.headers?.get?.('content-type') ?? '').toLowerCase(); + if (contentType && !contentType.includes('application/json') && !contentType.includes('+json')) { + const value: ServerFeaturesSnapshot = { status: 'unsupported', reason: 'invalid_payload' }; + cache.setSuccess(cacheKey, value, { ttlMs: getCacheTtlMs(value) }); + return value; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + const value: ServerFeaturesSnapshot = { status: 'unsupported', reason: 'invalid_payload' }; + cache.setSuccess(cacheKey, value, { ttlMs: getCacheTtlMs(value) }); + return value; + } + const parsed = parseServerFeatures(payload); if (!parsed) { const value: ServerFeaturesSnapshot = { status: 'unsupported', reason: 'invalid_payload' }; diff --git a/apps/ui/sources/sync/api/capabilities/serverFeaturesParse.spec.ts b/apps/ui/sources/sync/api/capabilities/serverFeaturesParse.spec.ts index 1b13dbe31..e54d08ec7 100644 --- a/apps/ui/sources/sync/api/capabilities/serverFeaturesParse.spec.ts +++ b/apps/ui/sources/sync/api/capabilities/serverFeaturesParse.spec.ts @@ -34,7 +34,7 @@ function createValidFeaturesResponse() { oauth: { providers: { github: { enabled: true, configured: true } } }, auth: { signup: { methods: [{ id: 'anonymous', enabled: true }] }, - login: { requiredProviders: [] }, + login: { methods: [], requiredProviders: [] }, recovery: { providerReset: { providers: [] } }, ui: { autoRedirect: { enabled: false, providerId: null } }, providers: { diff --git a/apps/ui/sources/sync/api/types/apiTypes.sessionMessages.test.ts b/apps/ui/sources/sync/api/types/apiTypes.sessionMessages.test.ts new file mode 100644 index 000000000..dc1380570 --- /dev/null +++ b/apps/ui/sources/sync/api/types/apiTypes.sessionMessages.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { ApiMessageSchema } from './apiTypes' + +describe('ApiMessageSchema', () => { + it('accepts encrypted message envelopes', () => { + const parsed = ApiMessageSchema.safeParse({ + id: 'm1', + seq: 1, + localId: null, + content: { t: 'encrypted', c: 'aGVsbG8=' }, + createdAt: 1, + }) + expect(parsed.success).toBe(true) + }) + + it('accepts plaintext message envelopes', () => { + const parsed = ApiMessageSchema.safeParse({ + id: 'm1', + seq: 1, + localId: null, + content: { t: 'plain', v: { kind: 'user-text', text: 'hello' } }, + createdAt: 1, + }) + expect(parsed.success).toBe(true) + }) +}) + diff --git a/apps/ui/sources/sync/api/types/apiTypes.ts b/apps/ui/sources/sync/api/types/apiTypes.ts index 6c7615fc2..a4f597d88 100644 --- a/apps/ui/sources/sync/api/types/apiTypes.ts +++ b/apps/ui/sources/sync/api/types/apiTypes.ts @@ -1,19 +1,17 @@ import { z } from 'zod'; import { ChangeEntrySchema, ChangesResponseSchema } from '@happier-dev/protocol/changes'; +import { SessionStoredMessageContentSchema } from '@happier-dev/protocol'; import { EphemeralUpdateSchema, type EphemeralUpdate, UpdateBodySchema, UpdateContainerSchema } from '@happier-dev/protocol/updates'; // -// Encrypted message +// Session message // export const ApiMessageSchema = z.object({ id: z.string(), seq: z.number(), localId: z.string().nullish(), - content: z.object({ - t: z.literal('encrypted'), - c: z.string(), // Base64 encoded encrypted content - }), + content: SessionStoredMessageContentSchema, createdAt: z.number(), }); diff --git a/apps/ui/sources/sync/domains/features/featureBuildPolicy.ts b/apps/ui/sources/sync/domains/features/featureBuildPolicy.ts index 37d361ff0..b493e565e 100644 --- a/apps/ui/sources/sync/domains/features/featureBuildPolicy.ts +++ b/apps/ui/sources/sync/domains/features/featureBuildPolicy.ts @@ -2,17 +2,36 @@ import { evaluateFeatureBuildPolicy, resolveEmbeddedFeaturePolicyEnv, resolveFeatureBuildPolicyFromEnvOrEmbedded, + type FeatureBuildPolicy, type FeatureBuildPolicyEvaluation, type FeatureId, } from '@happier-dev/protocol'; -const buildPolicy = resolveFeatureBuildPolicyFromEnvOrEmbedded({ - embeddedEnv: resolveEmbeddedFeaturePolicyEnv(process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV) ?? undefined, - // UI bundles must only read build-time injected public env vars. - allowRaw: process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW, - denyRaw: process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY, -}); +let cachedBuildPolicy: FeatureBuildPolicy | null = null; +let cachedBuildPolicyKey: string | null = null; + +function resolveBuildPolicyFromEnv(): FeatureBuildPolicy { + const embeddedEnv = resolveEmbeddedFeaturePolicyEnv(process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV) ?? undefined; + const allowRaw = process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW; + const denyRaw = process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY; + const key = `${embeddedEnv ?? ''}::${allowRaw ?? ''}::${denyRaw ?? ''}`; + + if (cachedBuildPolicy && cachedBuildPolicyKey === key) { + return cachedBuildPolicy; + } + + const next = resolveFeatureBuildPolicyFromEnvOrEmbedded({ + embeddedEnv, + // UI bundles must only read build-time injected public env vars. + allowRaw, + denyRaw, + }); + cachedBuildPolicy = next; + cachedBuildPolicyKey = key; + return next; +} export function getFeatureBuildPolicyDecision(featureId: FeatureId): FeatureBuildPolicyEvaluation { + const buildPolicy = resolveBuildPolicyFromEnv(); return evaluateFeatureBuildPolicy(buildPolicy, featureId); } diff --git a/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts b/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts index b7e0c9182..5e731f90c 100644 --- a/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts +++ b/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts @@ -36,6 +36,12 @@ export const UI_FEATURE_REGISTRY = { icon: { ioniconName: 'code-slash-outline', color: '#AF52DE' }, }, }, + 'encryption.plaintextStorage': { + settingsToggle: undefined, + }, + 'encryption.accountOptOut': { + settingsToggle: undefined, + }, voice: { settingsToggle: { showInSettings: true, @@ -108,6 +114,9 @@ export const UI_FEATURE_REGISTRY = { 'auth.recovery.providerReset': { settingsToggle: undefined, }, + 'auth.login.keyChallenge': { + settingsToggle: undefined, + }, 'auth.ui.recoveryKeyReminder': { settingsToggle: undefined, }, diff --git a/apps/ui/sources/sync/domains/messages/messageMetaTypes.forwardCompat.test.ts b/apps/ui/sources/sync/domains/messages/messageMetaTypes.forwardCompat.test.ts new file mode 100644 index 000000000..58e3b0f66 --- /dev/null +++ b/apps/ui/sources/sync/domains/messages/messageMetaTypes.forwardCompat.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { MessageMetaSchema } from './messageMetaTypes'; + +describe('MessageMetaSchema (forward compatibility)', () => { + it('does not reject unknown permissionMode or sentFrom', () => { + const parsed = MessageMetaSchema.parse({ + source: 'cli', + sentFrom: '__future_client__', + permissionMode: '__future_permission_mode__', + } as any); + + expect(parsed.sentFrom).toBe('unknown'); + expect((parsed as any).permissionMode).toBe('default'); + }); +}); + diff --git a/apps/ui/sources/sync/domains/messages/messageMetaTypes.ts b/apps/ui/sources/sync/domains/messages/messageMetaTypes.ts index 08f7d2af0..c5d3d37a8 100644 --- a/apps/ui/sources/sync/domains/messages/messageMetaTypes.ts +++ b/apps/ui/sources/sync/domains/messages/messageMetaTypes.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { PERMISSION_MODES } from '@/constants/PermissionModes'; +import { createSessionMessageMetaSchema } from '@happier-dev/protocol'; const DANGEROUS_META_KEYS = new Set(['__proto__', 'constructor', 'prototype']); @@ -13,28 +13,7 @@ function sanitizeMessageMetaObject(meta: Record<string, unknown>): Record<string } // Shared message metadata schema -export const MessageMetaSchema = z.object({ - sentFrom: z.string().optional(), // Source identifier (forward-compatible) - /** - * High-level origin of the message, used by agents to avoid treating - * self-sent client traffic as a "new prompt" event. - * - * Forward-compatible: unknown strings are allowed. - */ - source: z.union([z.enum(['ui', 'cli']), z.string()]).optional(), - permissionMode: z.enum(PERMISSION_MODES).optional(), // Permission mode for this message - model: z.string().nullable().optional(), // Model name for this message (null = reset) - fallbackModel: z.string().nullable().optional(), // Fallback model for this message (null = reset) - customSystemPrompt: z.string().nullable().optional(), // Custom system prompt for this message (null = reset) - appendSystemPrompt: z.string().nullable().optional(), // Append to system prompt for this message (null = reset) - allowedTools: z.array(z.string()).nullable().optional(), // Allowed tools for this message (null = reset) - disallowedTools: z.array(z.string()).nullable().optional(), // Disallowed tools for this message (null = reset) - displayText: z.string().optional(), // Optional text to display in UI instead of actual message text - // Structured UI-only metadata (versioned envelope). Unknown payload shapes are validated by renderers. - happier: z.object({ - kind: z.string(), - payload: z.unknown(), - }).optional(), -}).passthrough().transform((meta) => sanitizeMessageMetaObject(meta as Record<string, unknown>)); +export const MessageMetaSchema = createSessionMessageMetaSchema(z) + .transform((meta) => sanitizeMessageMetaObject(meta as Record<string, unknown>)); export type MessageMeta = z.infer<typeof MessageMetaSchema>; diff --git a/apps/ui/sources/sync/domains/server/serverProfiles.test.ts b/apps/ui/sources/sync/domains/server/serverProfiles.test.ts index 54059267f..4cfd9d732 100644 --- a/apps/ui/sources/sync/domains/server/serverProfiles.test.ts +++ b/apps/ui/sources/sync/domains/server/serverProfiles.test.ts @@ -97,6 +97,46 @@ describe('serverProfiles', () => { expect(profiles.listServerProfiles().some((p) => p.serverUrl === 'https://api.happier.dev')).toBe(false); }); + it('seeds a same-origin server profile on web when no preconfigured env exists', async () => { + const scope = randomScope(); + process.env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE = scope; + delete process.env.EXPO_PUBLIC_HAPPY_SERVER_URL; + delete process.env.EXPO_PUBLIC_HAPPY_PRECONFIGURED_SERVERS; + stubWebRuntime('https://selfhost.example.test'); + + const profiles = await importFresh(); + expect(profiles.listServerProfiles().some((p) => p.serverUrl === 'https://selfhost.example.test')).toBe(true); + expect(profiles.getActiveServerUrl()).toBe('https://selfhost.example.test'); + expect(profiles.getActiveServerId()).toBeTruthy(); + }); + + it('seeds api.happier.dev on app.happier.dev web origin when no preconfigured env exists', async () => { + const scope = randomScope(); + process.env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE = scope; + delete process.env.EXPO_PUBLIC_HAPPY_SERVER_URL; + delete process.env.EXPO_PUBLIC_HAPPY_PRECONFIGURED_SERVERS; + stubWebRuntime('https://app.happier.dev'); + + const profiles = await importFresh(); + const all = profiles.listServerProfiles(); + expect(all.some((p) => p.serverUrl === 'https://api.happier.dev')).toBe(true); + expect(all.some((p) => p.serverUrl === 'https://app.happier.dev')).toBe(false); + expect(profiles.getActiveServerUrl()).toBe('https://api.happier.dev'); + }); + + it('does not seed a same-origin server profile when EXPO_PUBLIC_HAPPY_SERVER_URL is set', async () => { + const scope = randomScope(); + process.env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE = scope; + process.env.EXPO_PUBLIC_HAPPY_SERVER_URL = 'https://configured.example.test'; + delete process.env.EXPO_PUBLIC_HAPPY_PRECONFIGURED_SERVERS; + stubWebRuntime('https://selfhost.example.test'); + + const profiles = await importFresh(); + const all = profiles.listServerProfiles(); + expect(all.some((p) => p.serverUrl === 'https://configured.example.test')).toBe(true); + expect(all.some((p) => p.serverUrl === 'https://selfhost.example.test')).toBe(false); + }); + it('derives deterministic filesystem-safe ids from server URLs', async () => { const scope = randomScope(); process.env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE = scope; diff --git a/apps/ui/sources/sync/domains/server/serverProfiles.ts b/apps/ui/sources/sync/domains/server/serverProfiles.ts index 3f311e15a..cee67f50d 100644 --- a/apps/ui/sources/sync/domains/server/serverProfiles.ts +++ b/apps/ui/sources/sync/domains/server/serverProfiles.ts @@ -133,6 +133,16 @@ function parsePreconfiguredServersFromEnv(): PreconfiguredServer[] { append(singleUrl, '', isStackContext() ? 'stack-env' : 'url'); } + // On web with no explicitly configured server, fall back to same-origin so that + // self-hosted deployments (e.g. https://happier.example.com) get a server profile + // without needing EXPO_PUBLIC_HAPPY_SERVER_URL set at build time. + if (entries.length === 0) { + const origin = getWebSameOriginServerUrl(); + if (origin) { + append(origin, '', 'url'); + } + } + return entries; } @@ -298,6 +308,12 @@ function getWebSameOriginServerUrl(): string | null { try { const parsed = new URL(origin); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + // Official hosted web app (app.happier.dev) is a static SPA; the API lives on api.happier.dev. + // When builds are missing EXPO_PUBLIC_HAPPY_SERVER_URL, this prevents the default server + // from incorrectly pointing at the web host. + if (parsed.hostname.toLowerCase() === 'app.happier.dev') { + return 'https://api.happier.dev'; + } return origin; } catch { return null; diff --git a/apps/ui/sources/sync/domains/session/activeViewingSession.ts b/apps/ui/sources/sync/domains/session/activeViewingSession.ts new file mode 100644 index 000000000..801574114 --- /dev/null +++ b/apps/ui/sources/sync/domains/session/activeViewingSession.ts @@ -0,0 +1,24 @@ +/** + * Module-scoped tracker for the session the user is currently viewing. + * + * The notification handler (`Notifications.setNotificationHandler`) runs outside + * the React component tree so it cannot use hooks. This singleton provides a + * synchronous way to check which session is on-screen, enabling same-session + * notification suppression. + */ + +let _activeViewingSessionId: string | null = null; + +export const getActiveViewingSessionId = (): string | null => _activeViewingSessionId; + +export const setActiveViewingSessionId = (sessionId: string): void => { + _activeViewingSessionId = sessionId; +}; + +export const clearActiveViewingSessionId = (sessionId: string): void => { + // Only clear if the current value matches — avoids a race when two + // SessionViews mount/unmount in rapid succession during navigation. + if (_activeViewingSessionId === sessionId) { + _activeViewingSessionId = null; + } +}; diff --git a/apps/ui/sources/sync/domains/session/listing/sessionListViewData.ts b/apps/ui/sources/sync/domains/session/listing/sessionListViewData.ts index 7ff48ccf8..c80142969 100644 --- a/apps/ui/sources/sync/domains/session/listing/sessionListViewData.ts +++ b/apps/ui/sources/sync/domains/session/listing/sessionListViewData.ts @@ -1,3 +1,4 @@ +import { isHiddenSystemSession } from '@happier-dev/protocol'; import type { Machine, Session } from '@/sync/domains/state/storageTypes'; export type SessionListViewItem = @@ -267,7 +268,7 @@ export function buildSessionListViewData( Object.values(sessions).forEach((session) => { // Hide system sessions from user-facing lists by default. - if (session.metadata?.systemSessionV1?.hidden === true) { + if (isHiddenSystemSession({ metadata: session.metadata })) { return; } if (isSessionActive(session)) { diff --git a/apps/ui/sources/sync/domains/settings/installablesPolicy.test.ts b/apps/ui/sources/sync/domains/settings/installablesPolicy.test.ts new file mode 100644 index 000000000..f6adffbd0 --- /dev/null +++ b/apps/ui/sources/sync/domains/settings/installablesPolicy.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { INSTALLABLE_KEYS } from '@happier-dev/protocol/installables'; + +import { resolveInstallablePolicy, applyInstallablePolicyOverride } from './installablesPolicy'; + +describe('installablesPolicy', () => { + it('resolves defaults when there is no override', () => { + const policy = resolveInstallablePolicy({ + settings: { installablesPolicyByMachineId: {} } as any, + machineId: 'm1', + installableKey: INSTALLABLE_KEYS.CODEX_ACP, + defaults: { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }, + }); + expect(policy).toEqual({ autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }); + }); + + it('applies machine/key overrides while preserving unspecified defaults', () => { + const policy = resolveInstallablePolicy({ + settings: { + installablesPolicyByMachineId: { + m1: { + [INSTALLABLE_KEYS.CODEX_ACP]: { autoUpdateMode: 'notify' }, + }, + }, + } as any, + machineId: 'm1', + installableKey: INSTALLABLE_KEYS.CODEX_ACP, + defaults: { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }, + }); + expect(policy).toEqual({ autoInstallWhenNeeded: true, autoUpdateMode: 'notify' }); + }); + + it('builds a next installablesPolicyByMachineId map with a key override', () => { + const next = applyInstallablePolicyOverride({ + prev: {}, + machineId: 'm1', + installableKey: INSTALLABLE_KEYS.CODEX_ACP, + patch: { autoInstallWhenNeeded: false }, + }); + expect(next).toEqual({ m1: { [INSTALLABLE_KEYS.CODEX_ACP]: { autoInstallWhenNeeded: false } } }); + }); +}); diff --git a/apps/ui/sources/sync/domains/settings/installablesPolicy.ts b/apps/ui/sources/sync/domains/settings/installablesPolicy.ts new file mode 100644 index 000000000..2b51a8389 --- /dev/null +++ b/apps/ui/sources/sync/domains/settings/installablesPolicy.ts @@ -0,0 +1,73 @@ +import type { InstallableAutoUpdateMode, InstallableDefaultPolicy } from '@/capabilities/installablesRegistry'; +import type { KnownSettings } from './settings'; + +export type InstallablePolicyOverride = Readonly<{ + autoInstallWhenNeeded?: boolean; + autoUpdateMode?: InstallableAutoUpdateMode; +}>; + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function readInstallablesPolicyByMachineId(settings: KnownSettings): Record<string, Record<string, InstallablePolicyOverride>> { + const raw = (settings as any).installablesPolicyByMachineId; + if (!isRecord(raw)) return {}; + const out: Record<string, Record<string, InstallablePolicyOverride>> = {}; + for (const [machineId, byKeyRaw] of Object.entries(raw)) { + if (!isRecord(byKeyRaw)) continue; + const byKey: Record<string, InstallablePolicyOverride> = {}; + for (const [k, v] of Object.entries(byKeyRaw)) { + if (!isRecord(v)) continue; + const autoInstallWhenNeeded = (v as any).autoInstallWhenNeeded; + const autoUpdateMode = (v as any).autoUpdateMode; + byKey[k] = { + ...(typeof autoInstallWhenNeeded === 'boolean' ? { autoInstallWhenNeeded } : {}), + ...((autoUpdateMode === 'off' || autoUpdateMode === 'notify' || autoUpdateMode === 'auto') ? { autoUpdateMode } : {}), + }; + } + out[machineId] = byKey; + } + return out; +} + +export function resolveInstallablePolicy(params: { + settings: KnownSettings; + machineId: string; + installableKey: string; + defaults: InstallableDefaultPolicy; +}): InstallableDefaultPolicy { + const overridesByMachineId = readInstallablesPolicyByMachineId(params.settings); + const overrides = overridesByMachineId[params.machineId]?.[params.installableKey] ?? null; + if (!overrides) return params.defaults; + + return { + autoInstallWhenNeeded: typeof overrides.autoInstallWhenNeeded === 'boolean' + ? overrides.autoInstallWhenNeeded + : params.defaults.autoInstallWhenNeeded, + autoUpdateMode: (overrides.autoUpdateMode === 'off' || overrides.autoUpdateMode === 'notify' || overrides.autoUpdateMode === 'auto') + ? overrides.autoUpdateMode + : params.defaults.autoUpdateMode, + }; +} + +export function applyInstallablePolicyOverride(params: { + prev: Record<string, Record<string, InstallablePolicyOverride>>; + machineId: string; + installableKey: string; + patch: InstallablePolicyOverride; +}): Record<string, Record<string, InstallablePolicyOverride>> { + const prevByMachine = params.prev[params.machineId] ?? {}; + const prevOverride = prevByMachine[params.installableKey] ?? {}; + return { + ...params.prev, + [params.machineId]: { + ...prevByMachine, + [params.installableKey]: { + ...prevOverride, + ...params.patch, + }, + }, + }; +} + diff --git a/apps/ui/sources/sync/domains/settings/localSettings.test.ts b/apps/ui/sources/sync/domains/settings/localSettings.test.ts new file mode 100644 index 000000000..d6d107631 --- /dev/null +++ b/apps/ui/sources/sync/domains/settings/localSettings.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { localSettingsDefaults, localSettingsParse } from './localSettings'; + +describe('localSettingsParse', () => { + it('returns defaults for non-object input', () => { + expect(localSettingsParse(null)).toEqual(localSettingsDefaults); + expect(localSettingsParse(undefined)).toEqual(localSettingsDefaults); + expect(localSettingsParse('nope')).toEqual(localSettingsDefaults); + }); + + it('migrates legacy uiFontSize to uiFontScale when uiFontScale is missing', () => { + const parsed = localSettingsParse({ uiFontSize: 'large' }); + expect(parsed.uiFontScale).toBeCloseTo(1.1, 5); + }); + + it('prefers uiFontScale over legacy uiFontSize when both are present', () => { + const parsed = localSettingsParse({ uiFontScale: 1.42, uiFontSize: 'xsmall' }); + expect(parsed.uiFontScale).toBeCloseTo(1.42, 5); + }); + + it('clamps uiFontScale to the supported range', () => { + const tooSmall = localSettingsParse({ uiFontScale: 0.01 }); + expect(tooSmall.uiFontScale).toBeGreaterThanOrEqual(0.5); + + const tooBig = localSettingsParse({ uiFontScale: 100 }); + expect(tooBig.uiFontScale).toBeLessThanOrEqual(2.5); + }); +}); + diff --git a/apps/ui/sources/sync/domains/settings/localSettings.ts b/apps/ui/sources/sync/domains/settings/localSettings.ts index c6380d51d..ab44cb026 100644 --- a/apps/ui/sources/sync/domains/settings/localSettings.ts +++ b/apps/ui/sources/sync/domains/settings/localSettings.ts @@ -10,6 +10,8 @@ export const LocalSettingsSchema = z.object({ devModeEnabled: z.boolean().describe('Enable developer menu in settings'), commandPaletteEnabled: z.boolean().describe('Enable CMD+K command palette (web only)'), themePreference: z.enum(['light', 'dark', 'adaptive']).describe('Theme preference: light, dark, or adaptive (follows system)'), + uiFontScale: z.number().describe('In-app UI font scale multiplier (stacks with OS font scale)'), + uiFontSize: z.enum(['xxsmall', 'xsmall', 'small', 'default', 'large', 'xlarge', 'xxlarge']).optional().describe('Deprecated: legacy in-app UI font size'), markdownCopyV2: z.boolean().describe('Replace native paragraph selection with long-press modal for full markdown copy'), sidebarCollapsed: z.boolean().describe('Collapse the permanent sidebar on tablets'), // CLI version acknowledgments - keyed by machineId @@ -34,6 +36,8 @@ export const localSettingsDefaults: LocalSettings = { devModeEnabled: false, commandPaletteEnabled: false, themePreference: 'adaptive', + uiFontScale: 1, + uiFontSize: 'default', markdownCopyV2: false, sidebarCollapsed: false, acknowledgedCliVersions: {}, @@ -49,7 +53,34 @@ export function localSettingsParse(settings: unknown): LocalSettings { if (!parsed.success) { return { ...localSettingsDefaults }; } - return { ...localSettingsDefaults, ...parsed.data }; + + const legacyScaleBySize: Record<string, number> = { + xxsmall: 0.8, + xsmall: 0.85, + small: 0.93, + default: 1, + large: 1.1, + xlarge: 1.2, + xxlarge: 1.3, + }; + + const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + + const UI_FONT_SCALE_MIN = 0.5; + const UI_FONT_SCALE_MAX = 2.5; + + const data = parsed.data as any; + const nextUiFontScaleRaw = + typeof data.uiFontScale === 'number' + ? data.uiFontScale + : (typeof data.uiFontSize === 'string' ? legacyScaleBySize[data.uiFontSize] : undefined); + + const nextUiFontScale = + typeof nextUiFontScaleRaw === 'number' && Number.isFinite(nextUiFontScaleRaw) + ? clamp(nextUiFontScaleRaw, UI_FONT_SCALE_MIN, UI_FONT_SCALE_MAX) + : localSettingsDefaults.uiFontScale; + + return { ...localSettingsDefaults, ...parsed.data, uiFontScale: nextUiFontScale }; } // diff --git a/apps/ui/sources/sync/domains/settings/settings.spec.ts b/apps/ui/sources/sync/domains/settings/settings.spec.ts index a13aef9d4..ba6db6352 100644 --- a/apps/ui/sources/sync/domains/settings/settings.spec.ts +++ b/apps/ui/sources/sync/domains/settings/settings.spec.ts @@ -22,6 +22,11 @@ describe('settings', () => { expect(settingsParse({})).toEqual(settingsDefaults); }); + it('includes installables policy map by default', () => { + const settings = settingsParse({}); + expect((settings as any).installablesPolicyByMachineId).toEqual({}); + }); + it('should parse valid settings object', () => { const validSettings = { viewInline: true diff --git a/apps/ui/sources/sync/domains/settings/settings.ts b/apps/ui/sources/sync/domains/settings/settings.ts index 256361ad1..ffd29201a 100644 --- a/apps/ui/sources/sync/domains/settings/settings.ts +++ b/apps/ui/sources/sync/domains/settings/settings.ts @@ -129,6 +129,24 @@ const SessionTmuxMachineOverrideSchema = z.object({ tmpDir: z.string().nullable(), }); +const InstallableAutoUpdateModeSchema = z.enum(['off', 'notify', 'auto']); + +const InstallablePolicySchema = z.object({ + // When true, Happier may automatically install this installable when it is required + // for a selected backend/session flow (best-effort, non-blocking). + autoInstallWhenNeeded: z.boolean().optional(), + // Auto-update mode policy: + // - off: never update automatically + // - notify: surface update availability in UI, require manual confirmation + // - auto: update automatically in the background (best-effort) + autoUpdateMode: InstallableAutoUpdateModeSchema.optional(), +}); + +const InstallablesPolicyByMachineIdSchema = z.record( + z.string(), + z.record(z.string(), InstallablePolicySchema).default({}), +).default({}); + const ServerSelectionGroupSchema = z.object({ id: z.string().min(1), name: z.string().min(1).max(100), @@ -360,6 +378,7 @@ const SettingsSchemaBase = z.object({ sessionTmuxIsolated: z.boolean().describe('Whether to use an isolated tmux server for new sessions'), sessionTmuxTmpDir: z.string().nullable().describe('Optional TMUX_TMPDIR override for isolated tmux server'), sessionTmuxByMachineId: z.record(z.string(), SessionTmuxMachineOverrideSchema).default({}).describe('Per-machine overrides for tmux session spawning'), + installablesPolicyByMachineId: InstallablesPolicyByMachineIdSchema.describe('Per-machine installables policy overrides (auto-install / auto-update)'), // Legacy combined toggle (kept for backward compatibility; see settingsParse migration) usePickerSearch: z.boolean().describe('Whether to show search in machine/path picker UIs (legacy combined toggle)'), useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'), @@ -526,6 +545,7 @@ export type Settings = KnownSettings & Record<string, unknown>; sessionTmuxIsolated: true, sessionTmuxTmpDir: null, sessionTmuxByMachineId: {}, + installablesPolicyByMachineId: {}, useEnhancedSessionWizard: false, usePickerSearch: false, useMachinePickerSearch: false, diff --git a/apps/ui/sources/sync/domains/social/sharingRequests/buildCreateSessionShareRequest.test.ts b/apps/ui/sources/sync/domains/social/sharingRequests/buildCreateSessionShareRequest.test.ts new file mode 100644 index 000000000..14f526f01 --- /dev/null +++ b/apps/ui/sources/sync/domains/social/sharingRequests/buildCreateSessionShareRequest.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCreateSessionShareRequest } from './buildCreateSessionShareRequest'; + +describe('buildCreateSessionShareRequest', () => { + it('omits encryptedDataKey for plaintext sessions', () => { + const req = buildCreateSessionShareRequest({ + sessionEncryptionMode: 'plain', + userId: 'u2', + accessLevel: 'view', + }); + expect(req).toEqual({ userId: 'u2', accessLevel: 'view' }); + }); + + it('requires encryptedDataKey for encrypted sessions', () => { + expect(() => + buildCreateSessionShareRequest({ + sessionEncryptionMode: 'e2ee', + userId: 'u2', + accessLevel: 'edit', + }), + ).toThrow('encryptedDataKey required'); + + const req = buildCreateSessionShareRequest({ + sessionEncryptionMode: 'e2ee', + userId: 'u2', + accessLevel: 'edit', + encryptedDataKey: 'AA==', + }); + expect(req).toEqual({ userId: 'u2', accessLevel: 'edit', encryptedDataKey: 'AA==' }); + }); +}); + diff --git a/apps/ui/sources/sync/domains/social/sharingRequests/buildCreateSessionShareRequest.ts b/apps/ui/sources/sync/domains/social/sharingRequests/buildCreateSessionShareRequest.ts new file mode 100644 index 000000000..ffed69363 --- /dev/null +++ b/apps/ui/sources/sync/domains/social/sharingRequests/buildCreateSessionShareRequest.ts @@ -0,0 +1,28 @@ +import type { CreateSessionShareRequest, ShareAccessLevel } from '@/sync/domains/social/sharingTypes'; + +export function buildCreateSessionShareRequest(params: { + sessionEncryptionMode: 'e2ee' | 'plain' | undefined; + userId: string; + accessLevel: ShareAccessLevel; + canApprovePermissions?: boolean; + encryptedDataKey?: string; +}): CreateSessionShareRequest { + const { sessionEncryptionMode, userId, accessLevel, canApprovePermissions } = params; + + const base: CreateSessionShareRequest = { + userId, + accessLevel, + ...(canApprovePermissions !== undefined ? { canApprovePermissions } : {}), + }; + + if (sessionEncryptionMode === 'plain') { + return base; + } + + const encryptedDataKey = params.encryptedDataKey; + if (typeof encryptedDataKey !== 'string' || encryptedDataKey.length === 0) { + throw new Error('encryptedDataKey required'); + } + return { ...base, encryptedDataKey }; +} + diff --git a/apps/ui/sources/sync/domains/social/sharingTypes.ts b/apps/ui/sources/sync/domains/social/sharingTypes.ts index 32dc8ee5f..c0eb36a4c 100644 --- a/apps/ui/sources/sync/domains/social/sharingTypes.ts +++ b/apps/ui/sources/sync/domains/social/sharingTypes.ts @@ -207,8 +207,8 @@ export interface CreateSessionShareRequest { accessLevel: ShareAccessLevel; /** Whether this recipient can approve permission prompts (edit/admin only) */ canApprovePermissions?: boolean; - /** Base64 encoded (v0 + box bundle) */ - encryptedDataKey: string; + /** Base64 encoded (v0 + box bundle); required for encrypted (`e2ee`) sessions only */ + encryptedDataKey?: string; } /** Response containing a single session share */ @@ -236,8 +236,9 @@ export interface CreatePublicShareRequest { * * @remarks * Base64 encoded. Typically encrypted with a key derived from the token. + * Optional for plaintext (`plain`) sessions. */ - encryptedDataKey: string; + encryptedDataKey?: string; /** * Optional expiration timestamp (milliseconds since epoch) * @@ -282,6 +283,8 @@ export interface AccessPublicShareResponse { id: string; /** Session sequence number */ seq: number; + /** Session encryption mode */ + encryptionMode: 'e2ee' | 'plain'; /** Creation timestamp (milliseconds since epoch) */ createdAt: number; /** Last update timestamp (milliseconds since epoch) */ @@ -301,8 +304,8 @@ export interface AccessPublicShareResponse { }; /** Access level (always 'view' for public shares) */ accessLevel: 'view'; - /** Encrypted data key for decrypting session (base64) */ - encryptedDataKey: string; + /** Encrypted data key for decrypting session (base64); null for plaintext sessions */ + encryptedDataKey: string | null; /** Session owner profile */ owner: ShareUserProfile; /** Whether consent is required (echoed) */ diff --git a/apps/ui/sources/sync/domains/state/storageTypes.permissionMode.test.ts b/apps/ui/sources/sync/domains/state/storageTypes.permissionMode.test.ts new file mode 100644 index 000000000..5259c735e --- /dev/null +++ b/apps/ui/sources/sync/domains/state/storageTypes.permissionMode.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { MetadataSchema } from './storageTypes'; + +describe('MetadataSchema (permissionMode forward compatibility)', () => { + it('does not reject metadata when permissionMode is unknown', () => { + const parsed = MetadataSchema.parse({ + path: '/tmp', + host: 'localhost', + permissionMode: '__unknown_mode__', + permissionModeUpdatedAt: 123, + } as any); + + expect((parsed as any).permissionMode).toBe('default'); + expect((parsed as any).permissionModeUpdatedAt).toBe(123); + }); +}); diff --git a/apps/ui/sources/sync/domains/state/storageTypes.systemSession.test.ts b/apps/ui/sources/sync/domains/state/storageTypes.systemSession.test.ts new file mode 100644 index 000000000..1c7c43313 --- /dev/null +++ b/apps/ui/sources/sync/domains/state/storageTypes.systemSession.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { readSystemSessionMetadataFromMetadata } from '@happier-dev/protocol'; +import { MetadataSchema } from './storageTypes'; + +describe('MetadataSchema (systemSessionV1)', () => { + it('preserves unknown systemSessionV1 fields for forward compatibility', () => { + const parsed = MetadataSchema.parse({ + path: '/tmp', + host: 'localhost', + systemSessionV1: { v: 1, key: 'voice_carrier', hidden: true, extra: 'x' }, + } as any); + + expect((parsed as any).systemSessionV1?.extra).toBe('x'); + + const system = readSystemSessionMetadataFromMetadata({ metadata: parsed }); + expect((system as any)?.extra).toBe('x'); + }); + + it('preserves unknown top-level metadata fields for forward compatibility', () => { + const parsed = MetadataSchema.parse({ + path: '/tmp', + host: 'localhost', + tag: 'MyTag', + } as any); + + expect((parsed as any).tag).toBe('MyTag'); + }); +}); diff --git a/apps/ui/sources/sync/domains/state/storageTypes.ts b/apps/ui/sources/sync/domains/state/storageTypes.ts index 9682bb5a5..e69565699 100644 --- a/apps/ui/sources/sync/domains/state/storageTypes.ts +++ b/apps/ui/sources/sync/domains/state/storageTypes.ts @@ -1,7 +1,14 @@ import { z } from "zod"; -import { PERMISSION_MODES } from "@/constants/PermissionModes"; import type { PermissionMode } from "@/constants/PermissionModes"; import type { ModelMode } from "@/sync/domains/permissions/permissionTypes"; +import { + createAcpConfigOptionOverridesV1Schema, + createAcpSessionModeOverrideV1Schema, + createModelOverrideV1Schema, + createSessionPermissionModeSchema, + createSessionTerminalMetadataSchema, + createSessionSystemSessionV1Schema, +} from "@happier-dev/protocol"; // // Agent states @@ -28,6 +35,7 @@ export const MetadataSchema = z.object({ kimiSessionId: z.string().optional(), // Kimi ACP session ID (opaque) kiloSessionId: z.string().optional(), // Kilo ACP session ID (opaque) piSessionId: z.string().optional(), // Pi RPC session ID (opaque) + copilotSessionId: z.string().optional(), // Copilot ACP session ID (opaque) auggieAllowIndexing: z.boolean().optional(), // Auggie indexing enablement (spawn-time) tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), @@ -96,38 +104,19 @@ export const MetadataSchema = z.object({ * - `acpSessionModesV1` mirrors the agent-reported current state. * - `acpSessionModeOverrideV1` is the user's requested mode, applied by the runner when possible. */ - acpSessionModeOverrideV1: z.object({ - v: z.literal(1), - updatedAt: z.number(), - modeId: z.string(), - }).optional(), + acpSessionModeOverrideV1: createAcpSessionModeOverrideV1Schema(z).optional(), /** * Desired ACP config option overrides selected by the user (UI/CLI). */ - acpConfigOptionOverridesV1: z.object({ - v: z.literal(1), - updatedAt: z.number(), - overrides: z.record(z.string(), z.object({ - updatedAt: z.number(), - value: z.union([z.string(), z.number(), z.boolean(), z.null()]), - })), - }).optional(), + acpConfigOptionOverridesV1: createAcpConfigOptionOverridesV1Schema(z).optional(), homeDir: z.string().optional(), // User's home directory on the machine happyHomeDir: z.string().optional(), // Happy configuration directory hostPid: z.number().optional(), // Process ID of the session sessionLogPath: z.string().optional(), // Session-specific CLI log file path - terminal: z.object({ - mode: z.enum(['plain', 'tmux']), - requested: z.enum(['plain', 'tmux']).optional(), - fallbackReason: z.string().optional(), - tmux: z.object({ - target: z.string(), - tmpDir: z.string().optional(), - }).optional(), - }).optional(), + terminal: createSessionTerminalMetadataSchema(z).optional(), flavor: z.string().nullish(), // Session flavor/variant identifier // Published by happy-cli so the app can seed permission state even before there are messages. - permissionMode: z.enum(PERMISSION_MODES).optional(), + permissionMode: createSessionPermissionModeSchema(z).optional(), permissionModeUpdatedAt: z.number().optional(), /** * Session-level model override selected by the user (UI/CLI). @@ -136,11 +125,7 @@ export const MetadataSchema = z.object({ * - Stored in session metadata for cross-device consistency * - Applied to outgoing user messages via `message.meta.model` where supported */ - modelOverrideV1: z.object({ - v: z.literal(1), - updatedAt: z.number(), - modelId: z.string(), - }).optional(), + modelOverrideV1: createModelOverrideV1Schema(z).optional(), /** * Local-only markers for committed transcript messages that should be treated as discarded * (e.g. when the user switches to terminal control and abandons unprocessed remote messages). @@ -156,12 +141,8 @@ export const MetadataSchema = z.object({ * System/hidden sessions created for internal control planes (voice, execution carrier, etc). * These should be excluded from user-facing lists by default. */ - systemSessionV1: z.object({ - v: z.literal(1), - key: z.string(), - hidden: z.boolean().optional(), - }).optional(), -}); + systemSessionV1: createSessionSystemSessionV1Schema(z).optional(), +}).passthrough(); export type Metadata = z.infer<typeof MetadataSchema>; @@ -200,6 +181,10 @@ export type AgentState = z.infer<typeof AgentStateSchema>; export interface Session { id: string, seq: number, + /** + * Session content encryption mode. Missing means legacy/unknown and should be treated as `e2ee`. + */ + encryptionMode?: 'e2ee' | 'plain', createdAt: number, updatedAt: number, active: boolean, diff --git a/apps/ui/sources/sync/encryption/sessionEncryption.decryptMessages.cacheBehavior.test.ts b/apps/ui/sources/sync/encryption/sessionEncryption.decryptMessages.cacheBehavior.test.ts index f4fe4ee8b..edde871df 100644 --- a/apps/ui/sources/sync/encryption/sessionEncryption.decryptMessages.cacheBehavior.test.ts +++ b/apps/ui/sources/sync/encryption/sessionEncryption.decryptMessages.cacheBehavior.test.ts @@ -5,6 +5,106 @@ import { SessionEncryption } from './sessionEncryption'; import { AES256Encryption } from './encryptor'; describe('SessionEncryption.decryptMessages (cache behavior)', () => { + it('returns plaintext messages without decrypting and caches them', async () => { + const cache = new EncryptionCache() + const sessionId = 's_plain' + + const encryptor = { + encrypt: async () => { + throw new Error('encrypt should not be called') + }, + decrypt: async () => { + throw new Error('decrypt should not be called') + }, + } + + const sessionEnc = new SessionEncryption(sessionId, encryptor as any, cache) + + const msg = { + id: 'm_plain_1', + seq: 1, + localId: null, + createdAt: 1, + updatedAt: 1, + content: { + t: 'plain' as const, + v: { role: 'user', content: { type: 'text', text: 'hello' } }, + }, + } + + const first = await sessionEnc.decryptMessages([msg as any]) + expect(first[0]).toBeTruthy() + expect(first[0]!.content).toEqual({ role: 'user', content: { type: 'text', text: 'hello' } }) + + const second = await sessionEnc.decryptMessages([msg as any]) + expect(second[0]).toBeTruthy() + expect(second[0]!.content).toEqual({ role: 'user', content: { type: 'text', text: 'hello' } }) + }) + + it('rehydrates plaintext messages when content changes for the same message id', async () => { + const cache = new EncryptionCache() + const sessionId = 's_plain_stream' + + const encryptor = { + encrypt: async () => { + throw new Error('encrypt should not be called') + }, + decrypt: async () => { + throw new Error('decrypt should not be called') + }, + } + + const sessionEnc = new SessionEncryption(sessionId, encryptor as any, cache) + + const base = { + id: 'm_plain_stream_1', + seq: 1, + localId: null, + createdAt: 1, + updatedAt: 1, + } + + const msg1 = { ...base, content: { t: 'plain' as const, v: { role: 'user', content: { type: 'text', text: 'partial' } } } } + const msg2 = { ...base, updatedAt: 2, content: { t: 'plain' as const, v: { role: 'user', content: { type: 'text', text: 'final' } } } } + + const first = await sessionEnc.decryptMessages([msg1 as any]) + expect(first[0]).toBeTruthy() + expect(first[0]!.content).toEqual({ role: 'user', content: { type: 'text', text: 'partial' } }) + + const second = await sessionEnc.decryptMessages([msg2 as any]) + expect(second[0]).toBeTruthy() + expect(second[0]!.content).toEqual({ role: 'user', content: { type: 'text', text: 'final' } }) + }) + + it('treats invalid plaintext envelopes as undecipherable (content: null)', async () => { + const cache = new EncryptionCache() + const sessionId = 's_plain_invalid' + + const encryptor = { + encrypt: async () => { + throw new Error('encrypt should not be called') + }, + decrypt: async () => { + throw new Error('decrypt should not be called') + }, + } + + const sessionEnc = new SessionEncryption(sessionId, encryptor as any, cache) + + const msg = { + id: 'm_plain_invalid_1', + seq: 1, + localId: null, + createdAt: 1, + updatedAt: 1, + content: { t: 'plain' as const, v: { kind: 'not-a-raw-record', text: 'hello' } }, + } + + const result = await sessionEnc.decryptMessages([msg as any]) + expect(result[0]).toBeTruthy() + expect(result[0]!.content).toBeNull() + }) + it('retries decrypting encrypted messages when a prior attempt failed (does not permanently cache null)', async () => { const cache = new EncryptionCache(); const sessionId = 's1'; diff --git a/apps/ui/sources/sync/encryption/sessionEncryption.ts b/apps/ui/sources/sync/encryption/sessionEncryption.ts index dd6e8ea3b..43541e90f 100644 --- a/apps/ui/sources/sync/encryption/sessionEncryption.ts +++ b/apps/ui/sources/sync/encryption/sessionEncryption.ts @@ -1,10 +1,17 @@ import { decodeBase64, encodeBase64 } from '@/encryption/base64'; -import { RawRecord } from '../typesRaw'; +import { RawRecordSchema, type RawRecord } from '../typesRaw'; import { ApiMessage } from '../api/types/apiTypes'; import { DecryptedMessage, Metadata, MetadataSchema, AgentState, AgentStateSchema } from '../domains/state/storageTypes'; import { EncryptionCache } from './encryptionCache'; import { Decryptor, Encryptor } from './encryptor'; +type EncryptedApiMessage = ApiMessage & { content: { t: 'encrypted'; c: string } }; + +function isEncryptedApiMessage(message: ApiMessage): message is EncryptedApiMessage { + const content: any = (message as any)?.content; + return Boolean(content && content.t === 'encrypted' && typeof content.c === 'string'); +} + export class SessionEncryption { private sessionId: string; private encryptor: Encryptor & Decryptor; @@ -34,17 +41,32 @@ export class SessionEncryption { return `enc:${len}:${start}:${end}`; }; + const computePlainValueFingerprint = (value: unknown): string => { + try { + const json = JSON.stringify(value); + const len = json.length; + const start = json.slice(0, 48); + const end = json.slice(Math.max(0, len - 48)); + return `plain:${len}:${start}:${end}`; + } catch { + return "plain:unserializable"; + } + }; + const computeMessageFingerprint = (message: ApiMessage): string => { const content: any = (message as any)?.content; if (content && content.t === 'encrypted' && typeof content.c === 'string') { return computeCiphertextFingerprint(content.c); } - return `plain:${String(content?.t ?? 'unknown')}`; + if (content && content.t === 'plain') { + return computePlainValueFingerprint(content.v); + } + return 'plain:unknown'; }; // Check cache for all messages first const results: (DecryptedMessage | null)[] = new Array(messages.length); - const toDecrypt: { index: number; message: ApiMessage; fingerprint: string }[] = []; + const toDecrypt: { index: number; message: EncryptedApiMessage; fingerprint: string }[] = []; for (let i = 0; i < messages.length; i++) { const message = messages[i]; @@ -63,10 +85,21 @@ export class SessionEncryption { results[i] = cached; continue; } - } else if (message.content.t === 'encrypted') { + } else if (isEncryptedApiMessage(message)) { toDecrypt.push({ index: i, message, fingerprint }); + } else if (message.content.t === 'plain') { + const parsed = RawRecordSchema.safeParse((message.content as any).v); + const result: DecryptedMessage = { + id: message.id, + seq: message.seq, + localId: message.localId ?? null, + content: parsed.success ? parsed.data : null, + createdAt: message.createdAt, + }; + results[i] = result; + this.cache.setCachedMessage(message.id, result, fingerprint); } else { - // Not encrypted or invalid + // Invalid content results[i] = { id: message.id, seq: message.seq, diff --git a/apps/ui/sources/sync/engine/automations/syncAutomations.test.ts b/apps/ui/sources/sync/engine/automations/syncAutomations.test.ts index 46e404f15..8fe6945cf 100644 --- a/apps/ui/sources/sync/engine/automations/syncAutomations.test.ts +++ b/apps/ui/sources/sync/engine/automations/syncAutomations.test.ts @@ -45,7 +45,6 @@ describe('syncAutomations', () => { delete process.env.HAPPIER_BUILD_FEATURES_DENY; vi.unstubAllGlobals(); vi.restoreAllMocks(); - vi.resetModules(); }); it('does not call /v2/automations when /v1/features is missing (404)', async () => { diff --git a/apps/ui/sources/sync/engine/pending/pendingQueueV2.decryptMapping.test.ts b/apps/ui/sources/sync/engine/pending/pendingQueueV2.decryptMapping.test.ts index 053e54b04..6b3e52860 100644 --- a/apps/ui/sources/sync/engine/pending/pendingQueueV2.decryptMapping.test.ts +++ b/apps/ui/sources/sync/engine/pending/pendingQueueV2.decryptMapping.test.ts @@ -70,6 +70,34 @@ describe('pendingQueueV2 decrypt mapping', () => { expect(messages[0]?.displayText).toBe('OK'); }); + it('maps plaintext pending rows without decrypting', async () => { + const sessionId = 's_plain_pending'; + const encryption = await createPendingQueueEncryption({ sessionId }); + + const responseJson = { + pending: [ + { + localId: 'a', + content: { t: 'plain', v: { role: 'user', content: { type: 'text', text: 'ok' } } }, + status: 'queued', + position: 0, + createdAt: 1, + updatedAt: 1, + }, + ], + }; + + await fetchAndApplyPendingMessagesV2({ + sessionId, + encryption, + request: async () => new Response(JSON.stringify(responseJson), { status: 200 }), + }); + + const messages = storage.getState().sessionPending[sessionId]?.messages ?? []; + expect(messages.map((m) => m.localId)).toEqual(['a']); + expect(messages[0]?.text).toBe('ok'); + }); + it('skips malformed pending rows and keeps valid queued + discarded rows after mixed decrypt outcomes', async () => { const sessionId = 's_test_mixed'; const encryption = await createPendingQueueEncryption({ sessionId, seedByte: 9 }); diff --git a/apps/ui/sources/sync/engine/pending/pendingQueueV2.optimisticThinking.test.ts b/apps/ui/sources/sync/engine/pending/pendingQueueV2.optimisticThinking.test.ts index d451e9d0b..0afb3c7e5 100644 --- a/apps/ui/sources/sync/engine/pending/pendingQueueV2.optimisticThinking.test.ts +++ b/apps/ui/sources/sync/engine/pending/pendingQueueV2.optimisticThinking.test.ts @@ -143,4 +143,58 @@ describe('pendingQueueV2 optimistic thinking', () => { expect(pendingState?.messages ?? []).toEqual([]); expect(storage.getState().sessions[sessionId].optimisticThinkingAt ?? null).toBeNull(); }); + + it('sends plaintext pending payloads when session encryptionMode is plain', async () => { + const sessionId = 's_test_plain_send'; + storage.getState().applySessions([buildSession({ sessionId, overrides: { encryptionMode: 'plain' } })]); + const encryption = await createPendingQueueEncryption({ sessionId, seedByte: 8 }); + + const bodies: unknown[] = []; + await enqueuePendingMessageV2({ + sessionId, + text: 'hello', + encryption, + request: async (_path, init) => { + bodies.push(JSON.parse(String(init?.body ?? 'null'))); + return new Response(null, { status: 200 }); + }, + }); + + expect(bodies).toHaveLength(1); + expect(bodies[0]).toEqual( + expect.objectContaining({ + localId: expect.any(String), + content: expect.objectContaining({ t: 'plain', v: expect.any(Object) }), + }), + ); + expect(bodies[0]).not.toEqual(expect.objectContaining({ ciphertext: expect.anything() })); + }); + + it('does not require a session encryption key when session encryptionMode is plain', async () => { + const sessionId = 's_test_plain_send_no_key'; + storage.getState().applySessions([buildSession({ sessionId, overrides: { encryptionMode: 'plain' } })]); + + const bodies: unknown[] = []; + const encryptionWithoutSessionKey = { + getSessionEncryption: () => null, + } as unknown as Encryption; + await enqueuePendingMessageV2({ + sessionId, + text: 'hello', + encryption: encryptionWithoutSessionKey, + request: async (_path, init) => { + bodies.push(JSON.parse(String(init?.body ?? 'null'))); + return new Response(null, { status: 200 }); + }, + }); + + expect(bodies).toHaveLength(1); + expect(bodies[0]).toEqual( + expect.objectContaining({ + localId: expect.any(String), + content: expect.objectContaining({ t: 'plain', v: expect.any(Object) }), + }), + ); + expect(bodies[0]).not.toEqual(expect.objectContaining({ ciphertext: expect.anything() })); + }); }); diff --git a/apps/ui/sources/sync/engine/pending/pendingQueueV2.ts b/apps/ui/sources/sync/engine/pending/pendingQueueV2.ts index 3e353072d..19fb8a61a 100644 --- a/apps/ui/sources/sync/engine/pending/pendingQueueV2.ts +++ b/apps/ui/sources/sync/engine/pending/pendingQueueV2.ts @@ -7,12 +7,13 @@ import { buildSessionAppendSystemPrompt } from '@/agents/prompt/buildSessionAppe import { getAgentCore, resolveAgentIdFromFlavor } from '@/agents/catalog/catalog'; import { resolveSentFrom } from '@/sync/domains/messages/sentFrom'; import { buildSendMessageMeta } from '@/sync/domains/messages/buildSendMessageMeta'; +import { SessionStoredMessageContentSchema, type SessionStoredMessageContent } from '@happier-dev/protocol'; type PendingStatus = 'queued' | 'discarded'; type PendingRow = { localId: string; - content: { t: 'encrypted'; c: string }; + content: SessionStoredMessageContent; status: PendingStatus; position: number; createdAt: number; @@ -46,7 +47,8 @@ function parsePendingRows(raw: unknown): PendingRow[] | null { if (typeof localId !== 'string' || localId.length === 0) continue; if (!isPlainObject(content)) continue; - if (content.t !== 'encrypted' || typeof content.c !== 'string' || content.c.length === 0) continue; + const contentParsed = SessionStoredMessageContentSchema.safeParse(content); + if (!contentParsed.success) continue; if (status !== 'queued' && status !== 'discarded') continue; if (typeof position !== 'number' || !Number.isFinite(position)) continue; if (typeof createdAt !== 'number' || !Number.isFinite(createdAt)) continue; @@ -54,7 +56,7 @@ function parsePendingRows(raw: unknown): PendingRow[] | null { out.push({ localId, - content: { t: 'encrypted', c: content.c as string }, + content: contentParsed.data, status, position, createdAt, @@ -94,8 +96,10 @@ export async function fetchAndApplyPendingMessagesV2(params: { }): Promise<void> { const { sessionId, encryption, request } = params; - const sessionEncryption = encryption.getSessionEncryption(sessionId); - if (!sessionEncryption) { + const session = storage.getState().sessions[sessionId] ?? null; + const sessionEncryptionMode: 'e2ee' | 'plain' = session?.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + const sessionEncryption = sessionEncryptionMode === 'plain' ? null : encryption.getSessionEncryption(sessionId); + if (sessionEncryptionMode === 'e2ee' && !sessionEncryption) { storage.getState().applyPendingLoaded(sessionId); storage.getState().applyDiscardedPendingMessages(sessionId, []); return; @@ -121,7 +125,12 @@ export async function fetchAndApplyPendingMessagesV2(params: { const pendingMessages = []; for (const r of queued) { - const decrypted = await sessionEncryption.decryptRaw(r.content.c).catch(() => null); + const decrypted = + r.content.t === 'encrypted' + ? sessionEncryption + ? await sessionEncryption.decryptRaw(r.content.c).catch(() => null) + : null + : r.content.v; const coerced = coercePendingUserTextRecord(decrypted); if (!coerced) continue; pendingMessages.push({ @@ -137,7 +146,12 @@ export async function fetchAndApplyPendingMessagesV2(params: { const discardedMessages = []; for (const r of discarded) { - const decrypted = await sessionEncryption.decryptRaw(r.content.c).catch(() => null); + const decrypted = + r.content.t === 'encrypted' + ? sessionEncryption + ? await sessionEncryption.decryptRaw(r.content.c).catch(() => null) + : null + : r.content.v; const coerced = coercePendingUserTextRecord(decrypted); if (!coerced) continue; discardedMessages.push({ @@ -169,17 +183,17 @@ export async function enqueuePendingMessageV2(params: { storage.getState().markSessionOptimisticThinking(sessionId); - const sessionEncryption = encryption.getSessionEncryption(sessionId); - if (!sessionEncryption) { - storage.getState().clearSessionOptimisticThinking(sessionId); - throw new Error(`Session ${sessionId} not found`); - } - const session = storage.getState().sessions[sessionId]; if (!session) { storage.getState().clearSessionOptimisticThinking(sessionId); throw new Error(`Session ${sessionId} not found in storage`); } + const sessionEncryptionMode: 'e2ee' | 'plain' = session.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + const sessionEncryption = sessionEncryptionMode === 'plain' ? null : encryption.getSessionEncryption(sessionId); + if (sessionEncryptionMode === 'e2ee' && !sessionEncryption) { + storage.getState().clearSessionOptimisticThinking(sessionId); + throw new Error(`Session ${sessionId} not found`); + } const permissionMode = session.permissionMode || 'default'; const flavor = session.metadata?.flavor; @@ -207,12 +221,18 @@ export async function enqueuePendingMessageV2(params: { const createdAt = nowServerMs(); const updatedAt = createdAt; - let ciphertext: string; - try { - ciphertext = await sessionEncryption.encryptRawRecord(rawRecord); - } catch (e) { - storage.getState().clearSessionOptimisticThinking(sessionId); - throw e; + let writeBody: Record<string, unknown>; + if (sessionEncryptionMode === 'plain') { + writeBody = { localId, content: { t: 'plain', v: rawRecord } }; + } else { + let ciphertext: string; + try { + ciphertext = await sessionEncryption!.encryptRawRecord(rawRecord); + } catch (e) { + storage.getState().clearSessionOptimisticThinking(sessionId); + throw e; + } + writeBody = { localId, ciphertext }; } storage.getState().upsertPendingMessage(sessionId, { @@ -229,7 +249,7 @@ export async function enqueuePendingMessageV2(params: { const response = await request(`/v2/sessions/${sessionId}/pending`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ localId, ciphertext }), + body: JSON.stringify(writeBody), }); if (!response.ok) { throw new Error(`Failed to enqueue pending message (${response.status})`); @@ -251,8 +271,10 @@ export async function updatePendingMessageV2(params: { }): Promise<void> { const { sessionId, pendingId, text, encryption, request } = params; - const sessionEncryption = encryption.getSessionEncryption(sessionId); - if (!sessionEncryption) { + const session = storage.getState().sessions[sessionId] ?? null; + const sessionEncryptionMode: 'e2ee' | 'plain' = session?.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + const sessionEncryption = sessionEncryptionMode === 'plain' ? null : encryption.getSessionEncryption(sessionId); + if (sessionEncryptionMode === 'e2ee' && !sessionEncryption) { throw new Error(`Session ${sessionId} not found`); } @@ -297,13 +319,16 @@ export async function updatePendingMessageV2(params: { }; })(); - const ciphertext = await sessionEncryption.encryptRawRecord(rawRecord); + const writeBody = + sessionEncryptionMode === 'plain' + ? { content: { t: 'plain', v: rawRecord } } + : { ciphertext: await sessionEncryption!.encryptRawRecord(rawRecord) }; const updatedAt = nowServerMs(); const response = await request(`/v2/sessions/${sessionId}/pending/${pendingId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ciphertext }), + body: JSON.stringify(writeBody), }); if (!response.ok) { throw new Error(`Failed to update pending message (${response.status})`); diff --git a/apps/ui/sources/sync/engine/pending/pendingQueueV2.updatePendingMessageV2.test.ts b/apps/ui/sources/sync/engine/pending/pendingQueueV2.updatePendingMessageV2.test.ts index 6d589a155..48e0f366a 100644 --- a/apps/ui/sources/sync/engine/pending/pendingQueueV2.updatePendingMessageV2.test.ts +++ b/apps/ui/sources/sync/engine/pending/pendingQueueV2.updatePendingMessageV2.test.ts @@ -77,6 +77,56 @@ describe('pendingQueueV2 updatePendingMessageV2', () => { expect(decrypted?.meta?.displayText).toBe('Old display'); }); + it('sends plaintext pending updates when session encryptionMode is plain', async () => { + const sessionId = 's_test_plain_update'; + const encryption = await createPendingQueueEncryption({ sessionId }); + + storage.setState( + { + ...storage.getState(), + sessions: { + ...storage.getState().sessions, + [sessionId]: { + ...buildSession({ sessionId, overrides: { encryptionMode: 'plain' } }), + metadata: { path: '/tmp', host: 'h' }, + } as Session, + }, + }, + true, + ); + + storage.getState().upsertPendingMessage(sessionId, { + id: 'p1', + localId: 'p1', + createdAt: 1, + updatedAt: 1, + text: 'old', + rawRecord: { + role: 'user', + content: { type: 'text', text: 'old' }, + meta: {}, + }, + }); + + let capturedContent: any = null; + const request = async (_path: string, init?: RequestInit) => { + const parsed = JSON.parse(String(init?.body ?? 'null')); + capturedContent = parsed?.content ?? null; + return new Response('{}', { status: 200 }); + }; + + await updatePendingMessageV2({ + sessionId, + pendingId: 'p1', + text: 'new text', + encryption, + request, + }); + + expect(capturedContent).toEqual(expect.objectContaining({ t: 'plain', v: expect.any(Object) })); + expect(capturedContent?.v?.content?.text).toBe('new text'); + }); + it('injects execution-run guidance into appendSystemPrompt when enabled in settings', async () => { const sessionId = 's_test_guidance'; const encryption = await createPendingQueueEncryption({ sessionId, seedByte: 6 }); diff --git a/apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts b/apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts index 732bc54e4..8330c7371 100644 --- a/apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts +++ b/apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts @@ -1,7 +1,8 @@ +import { V2SessionListResponseSchema, type V2SessionListResponse } from '@happier-dev/protocol'; import type { AuthCredentials } from '@/auth/storage/tokenStorage'; import { HappyError } from '@/utils/errors/errors'; import { serverFetch } from '@/sync/http/client'; -import type { Session } from '@/sync/domains/state/storageTypes'; +import { AgentStateSchema, MetadataSchema, type Session } from '@/sync/domains/state/storageTypes'; import type { Metadata } from '@/sync/domains/state/storageTypes'; type SessionEncryption = { @@ -30,26 +31,7 @@ export async function fetchAndApplySessions(params: { ?? ((path: string, init: RequestInit) => serverFetch(path, init, { includeAuth: false })); const SESSION_LIST_LIMIT = 150; - const sessions: Array<{ - id: string; - seq: number; - pendingVersion?: number; - pendingCount?: number; - metadata: string; - metadataVersion: number; - agentState: string | null; - agentStateVersion: number; - dataEncryptionKey: string | null; - active: boolean; - activeAt: number; - archivedAt?: number | null; - createdAt: number; - updatedAt: number; - share?: { - accessLevel: 'view' | 'edit' | 'admin'; - canApprovePermissions: boolean; - } | null; - }> = []; + const sessions: V2SessionListResponse['sessions'] = []; let cursor: string | null = null; while (sessions.length < SESSION_LIST_LIMIT) { @@ -73,18 +55,17 @@ export async function fetchAndApplySessions(params: { } const data = await response.json(); - const pageSessions = (data as any)?.sessions; - if (!Array.isArray(pageSessions)) { + const parsed = V2SessionListResponseSchema.safeParse(data); + if (!parsed.success) { throw new Error('Invalid /v2/sessions response'); } - for (const raw of pageSessions) { - if (!raw || typeof raw !== 'object') continue; - sessions.push(raw); + for (const row of parsed.data.sessions) { + sessions.push(row); } - const hasNext = (data as any)?.hasNext === true; - const nextCursor = typeof (data as any)?.nextCursor === 'string' ? (data as any).nextCursor : null; + const hasNext = parsed.data.hasNext === true; + const nextCursor = typeof parsed.data.nextCursor === 'string' ? parsed.data.nextCursor : null; if (!hasNext || !nextCursor) break; cursor = nextCursor; } @@ -112,27 +93,57 @@ export async function fetchAndApplySessions(params: { // Decrypt sessions const decryptedSessions: (Omit<Session, 'presence'> & { presence?: 'online' | number })[] = []; for (const session of sessions) { - // Get session encryption (should always exist after initialization) + const encryptionMode: 'e2ee' | 'plain' = session.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + const sessionEncryption = encryption.getSessionEncryption(session.id); - if (!sessionEncryption) { + if (encryptionMode === 'e2ee' && !sessionEncryption) { console.error(`Session encryption not found for ${session.id} - this should never happen`); continue; } - // Decrypt metadata using session-specific encryption - const metadata = await sessionEncryption.decryptMetadata(session.metadataVersion, session.metadata); + const parsePlainMetadata = (value: string): Metadata | null => { + try { + const parsedJson = JSON.parse(value); + const parsed = MetadataSchema.safeParse(parsedJson); + return parsed.success ? parsed.data : null; + } catch { + return null; + } + }; + + const parsePlainAgentState = (value: string | null): unknown => { + if (!value) return {}; + try { + const parsedJson = JSON.parse(value); + const parsed = AgentStateSchema.safeParse(parsedJson); + return parsed.success ? parsed.data : {}; + } catch { + return {}; + } + }; + + const metadata = + encryptionMode === 'plain' + ? parsePlainMetadata(session.metadata) + : await sessionEncryption!.decryptMetadata(session.metadataVersion, session.metadata); - // Decrypt agent state using session-specific encryption - const agentState = await sessionEncryption.decryptAgentState(session.agentStateVersion, session.agentState); + const agentState = + encryptionMode === 'plain' + ? parsePlainAgentState(session.agentState) + : await sessionEncryption!.decryptAgentState(session.agentStateVersion, session.agentState); // Put it all together + const accessLevel = session.share?.accessLevel; + const normalizedAccessLevel = + accessLevel === 'view' || accessLevel === 'edit' || accessLevel === 'admin' ? accessLevel : undefined; decryptedSessions.push({ ...session, + encryptionMode, thinking: false, thinkingAt: 0, metadata, agentState, - accessLevel: session.share?.accessLevel ?? undefined, + accessLevel: normalizedAccessLevel, canApprovePermissions: session.share?.canApprovePermissions ?? undefined, }); } diff --git a/apps/ui/sources/sync/engine/sessions/syncSessions.ts b/apps/ui/sources/sync/engine/sessions/syncSessions.ts index 46444d063..e79cfa48a 100644 --- a/apps/ui/sources/sync/engine/sessions/syncSessions.ts +++ b/apps/ui/sources/sync/engine/sessions/syncSessions.ts @@ -1,8 +1,7 @@ import type { NormalizedMessage, RawRecord } from '@/sync/typesRaw'; import { normalizeRawMessage } from '@/sync/typesRaw'; import { computeNextSessionSeqFromUpdate } from '@/sync/domains/session/sequence/realtimeSessionSeq'; -import type { Session } from '@/sync/domains/state/storageTypes'; -import type { Metadata } from '@/sync/domains/state/storageTypes'; +import { AgentStateSchema, MetadataSchema, type Session, type Metadata } from '@/sync/domains/state/storageTypes'; import { computeNextReadStateV1 } from '@/sync/domains/state/readStateV1'; import type { ApiMessage, ApiSessionMessagesResponse } from '@/sync/api/types/apiTypes'; import { ApiSessionMessagesResponseSchema } from '@/sync/api/types/apiTypes'; @@ -53,16 +52,44 @@ export async function buildUpdatedSessionFromSocketUpdate(params: { }): Promise<{ nextSession: Session; agentState: any }> { const { session, updateBody, updateSeq, updateCreatedAt, sessionEncryption } = params; + const encryptionMode: 'e2ee' | 'plain' = session.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + + const parsePlainMetadata = (value: string): Metadata | null => { + try { + const parsedJson = JSON.parse(value); + const parsed = MetadataSchema.safeParse(parsedJson); + return parsed.success ? parsed.data : null; + } catch { + return null; + } + }; + + const parsePlainAgentState = (value: string | null): unknown => { + if (!value) return {}; + try { + const parsedJson = JSON.parse(value); + const parsed = AgentStateSchema.safeParse(parsedJson); + return parsed.success ? parsed.data : {}; + } catch { + return {}; + } + }; + const agentState = updateBody.agentState - ? await sessionEncryption.decryptAgentState(updateBody.agentState.version, updateBody.agentState.value) + ? encryptionMode === 'plain' + ? parsePlainAgentState(updateBody.agentState.value) + : await sessionEncryption.decryptAgentState(updateBody.agentState.version, updateBody.agentState.value) : session.agentState; const metadata = updateBody.metadata - ? await sessionEncryption.decryptMetadata(updateBody.metadata.version, updateBody.metadata.value) + ? encryptionMode === 'plain' + ? parsePlainMetadata(updateBody.metadata.value) + : await sessionEncryption.decryptMetadata(updateBody.metadata.version, updateBody.metadata.value) : session.metadata; const nextSession: Session = { ...session, + encryptionMode, agentState, agentStateVersion: updateBody.agentState ? updateBody.agentState.version : session.agentStateVersion, metadata, diff --git a/apps/ui/sources/sync/engine/sessions/syncSessions.updateSessionSocket.plaintext.test.ts b/apps/ui/sources/sync/engine/sessions/syncSessions.updateSessionSocket.plaintext.test.ts new file mode 100644 index 000000000..22382893c --- /dev/null +++ b/apps/ui/sources/sync/engine/sessions/syncSessions.updateSessionSocket.plaintext.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import type { Session } from '@/sync/domains/state/storageTypes'; +import { buildUpdatedSessionFromSocketUpdate } from './syncSessions'; + +function createSession(params: { sessionId: string; encryptionMode: 'plain' | 'e2ee' }): Session { + const now = 1_700_000_000_000; + return { + id: params.sessionId, + seq: 1, + encryptionMode: params.encryptionMode, + createdAt: now, + updatedAt: now, + active: true, + activeAt: now, + metadata: { path: '/tmp', host: 'localhost' }, + metadataVersion: 1, + agentState: {}, + agentStateVersion: 1, + thinking: false, + thinkingAt: 0, + presence: 'online', + optimisticThinkingAt: null, + }; +} + +describe('buildUpdatedSessionFromSocketUpdate (plaintext)', () => { + it('parses plaintext metadata and agentState when session encryptionMode is plain', async () => { + const base = createSession({ sessionId: 's1', encryptionMode: 'plain' }); + + const updateBody = { + metadata: { version: 2, value: JSON.stringify({ path: '/work', host: 'devbox' }) }, + agentState: { version: 3, value: JSON.stringify({ controlledByUser: true }) }, + }; + + const { nextSession } = await buildUpdatedSessionFromSocketUpdate({ + session: base, + updateBody, + updateSeq: 10, + updateCreatedAt: 1234, + sessionEncryption: { + decryptAgentState: async () => { + throw new Error('decryptAgentState should not be called for plaintext sessions'); + }, + decryptMetadata: async () => { + throw new Error('decryptMetadata should not be called for plaintext sessions'); + }, + }, + }); + + expect(nextSession.encryptionMode).toBe('plain'); + expect(nextSession.metadataVersion).toBe(2); + expect(nextSession.metadata).toEqual({ path: '/work', host: 'devbox' }); + expect(nextSession.agentStateVersion).toBe(3); + const agentState = nextSession.agentState as unknown as { controlledByUser?: unknown }; + expect(agentState.controlledByUser).toBe(true); + }); +}); diff --git a/apps/ui/sources/sync/engine/sessions/syncSessions.v2SessionsFetch.test.ts b/apps/ui/sources/sync/engine/sessions/syncSessions.v2SessionsFetch.test.ts index ea72a3257..51bd33dcf 100644 --- a/apps/ui/sources/sync/engine/sessions/syncSessions.v2SessionsFetch.test.ts +++ b/apps/ui/sources/sync/engine/sessions/syncSessions.v2SessionsFetch.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AuthCredentials } from '@/auth/storage/tokenStorage'; import { HappyError } from '@/utils/errors/errors'; +import { encodeV2SessionListCursorV1, type V2SessionRecord } from '@happier-dev/protocol'; import { fetchAndApplySessions, type SessionListEncryption } from './sessionSnapshot'; @@ -14,24 +15,7 @@ vi.mock('@/sync/domains/server/serverRuntime', () => ({ }), })); -type SessionRow = { - id: string; - seq: number; - createdAt: number; - updatedAt: number; - active: boolean; - activeAt: number; - archivedAt?: number | null; - metadata: string; - metadataVersion: number; - agentState: string | null; - agentStateVersion: number; - dataEncryptionKey: string | null; - share: { - accessLevel: 'view' | 'edit' | 'admin'; - canApprovePermissions: boolean; - } | null; -}; +type SessionRow = V2SessionRecord; function buildSessionRow(overrides: Partial<SessionRow> & Pick<SessionRow, 'id'>): SessionRow { const { id, ...rest } = overrides; @@ -88,6 +72,51 @@ afterEach(() => { }); describe('fetchAndApplySessions (/v2/sessions snapshot)', () => { + it('bypasses decrypt for plaintext sessions and parses metadata/agentState JSON', async () => { + const requestSpy = vi.fn(async () => + jsonResponse({ + sessions: [ + buildSessionRow({ + id: 's_plain', + dataEncryptionKey: null, + encryptionMode: 'plain', + metadata: JSON.stringify({ path: '/repo', host: 'dev' }), + agentState: JSON.stringify({}), + }), + ], + nextCursor: null, + hasNext: false, + }), + ); + + const { encryption, decryptMetadata, decryptAgentState } = createEncryptionHarness(); + const appliedSessions: Array<Record<string, unknown>> = []; + + await fetchAndApplySessions({ + credentials: { token: 't', secret: 's' }, + encryption, + sessionDataKeys: new Map<string, Uint8Array>(), + request: requestSpy, + applySessions: (sessions) => { + appliedSessions.push(...(sessions as unknown as Array<Record<string, unknown>>)); + }, + repairInvalidReadStateV1: async () => {}, + log: { log: () => {} }, + }); + + expect(decryptMetadata).not.toHaveBeenCalled(); + expect(decryptAgentState).not.toHaveBeenCalled(); + expect(appliedSessions).toHaveLength(1); + expect(appliedSessions[0]).toEqual( + expect.objectContaining({ + id: 's_plain', + encryptionMode: 'plain', + metadata: expect.objectContaining({ path: '/repo', host: 'dev' }), + agentState: {}, + }), + ); + }); + it('pages through /v2/sessions and applies decrypted sessions with share and key cache mapping', async () => { const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { const parsed = new URL(typeof input === 'string' ? input : String(input)); @@ -105,12 +134,12 @@ describe('fetchAndApplySessions (/v2/sessions snapshot)', () => { share: { accessLevel: 'view', canApprovePermissions: true }, }), ], - nextCursor: 'cursor_v1_s1', + nextCursor: encodeV2SessionListCursorV1('s1'), hasNext: true, }); } - expect(cursor).toBe('cursor_v1_s1'); + expect(cursor).toBe(encodeV2SessionListCursorV1('s1')); return jsonResponse({ sessions: [ buildSessionRow({ id: 's0', seq: 0, active: false, activeAt: 0, dataEncryptionKey: 'k0' }), diff --git a/apps/ui/sources/sync/engine/social/syncFriends.feat.social.friends.test.ts b/apps/ui/sources/sync/engine/social/syncFriends.feat.social.friends.test.ts index d2a0400c1..e44095181 100644 --- a/apps/ui/sources/sync/engine/social/syncFriends.feat.social.friends.test.ts +++ b/apps/ui/sources/sync/engine/social/syncFriends.feat.social.friends.test.ts @@ -43,7 +43,6 @@ describe('syncFriends', () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); - vi.resetModules(); resetServerFeaturesClientForTests(); }); diff --git a/apps/ui/sources/sync/engine/socket/socket.automationUpdates.test.ts b/apps/ui/sources/sync/engine/socket/socket.automationUpdates.test.ts index 840545a27..c3709e12f 100644 --- a/apps/ui/sources/sync/engine/socket/socket.automationUpdates.test.ts +++ b/apps/ui/sources/sync/engine/socket/socket.automationUpdates.test.ts @@ -21,6 +21,7 @@ function buildBaseParams(overrides: Partial<Omit<Parameters<typeof handleUpdateC invalidateMessagesForSession: vi.fn(), assumeUsers: vi.fn(async () => {}), applyTodoSocketUpdates: vi.fn(async () => {}), + invalidateMachines: vi.fn(), invalidateSessions: vi.fn(), invalidateArtifacts: vi.fn(), invalidateFriends: vi.fn(), diff --git a/apps/ui/sources/sync/engine/socket/socket.cursorIsolation.test.ts b/apps/ui/sources/sync/engine/socket/socket.cursorIsolation.test.ts index 8ed62fed1..ad7fd5356 100644 --- a/apps/ui/sources/sync/engine/socket/socket.cursorIsolation.test.ts +++ b/apps/ui/sources/sync/engine/socket/socket.cursorIsolation.test.ts @@ -44,6 +44,7 @@ function buildBaseParams(overrides: Partial<Omit<Parameters<typeof handleUpdateC invalidateMessagesForSession: vi.fn(), assumeUsers: vi.fn(async () => {}), applyTodoSocketUpdates: vi.fn(async () => {}), + invalidateMachines: vi.fn(), invalidateSessions: vi.fn(), invalidateArtifacts: vi.fn(), invalidateFriends: vi.fn(), diff --git a/apps/ui/sources/sync/engine/socket/socket.newMachineUpdates.test.ts b/apps/ui/sources/sync/engine/socket/socket.newMachineUpdates.test.ts new file mode 100644 index 000000000..db1171889 --- /dev/null +++ b/apps/ui/sources/sync/engine/socket/socket.newMachineUpdates.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ApiUpdateContainer } from '@/sync/api/types/apiTypes'; +import type { Machine } from '@/sync/domains/state/storageTypes'; +import { storage } from '@/sync/domains/state/storage'; +import { flushMachineActivityUpdates, handleEphemeralSocketUpdate, handleUpdateContainer } from './socket'; + +const initialStorageState = storage.getState(); + +function buildBaseParams(overrides: Partial<Omit<Parameters<typeof handleUpdateContainer>[0], 'updateData'>> = {}) { + return { + encryption: { + getSessionEncryption: () => null, + getMachineEncryption: () => null, + removeSessionEncryption: () => {}, + } as unknown as Parameters<typeof handleUpdateContainer>[0]['encryption'], + artifactDataKeys: new Map<string, Uint8Array>(), + applySessions: vi.fn(), + fetchSessions: vi.fn(), + applyMessages: vi.fn(), + onSessionVisible: vi.fn(), + isSessionMessagesLoaded: vi.fn(() => false), + getSessionMaterializedMaxSeq: vi.fn(() => 0), + markSessionMaterializedMaxSeq: vi.fn(), + invalidateMessagesForSession: vi.fn(), + assumeUsers: vi.fn(async () => {}), + applyTodoSocketUpdates: vi.fn(async () => {}), + invalidateMachines: vi.fn(), + invalidateSessions: vi.fn(), + invalidateArtifacts: vi.fn(), + invalidateFriends: vi.fn(), + invalidateFriendRequests: vi.fn(), + invalidateFeed: vi.fn(), + invalidateAutomations: vi.fn(), + invalidateTodos: vi.fn(), + log: { log: vi.fn() }, + ...overrides, + }; +} + +describe('socket update handling: new-machine', () => { + beforeEach(() => { + storage.setState(initialStorageState, true); + }); + + it('applies a placeholder machine and invalidates machines sync', async () => { + const invalidateMachines = vi.fn(); + const params = buildBaseParams({ invalidateMachines }); + const updateData: ApiUpdateContainer = { + id: 'u_machine_1', + seq: 42, + createdAt: 123, + body: { + t: 'new-machine', + machineId: 'm1', + seq: 7, + metadata: 'AA==', + metadataVersion: 1, + daemonState: null, + daemonStateVersion: 0, + dataEncryptionKey: null, + active: false, + activeAt: 120, + createdAt: 100, + updatedAt: 110, + }, + } as ApiUpdateContainer; + + await handleUpdateContainer({ ...params, updateData }); + + expect(invalidateMachines).toHaveBeenCalledTimes(1); + + const machine = storage.getState().machines['m1'] as Machine | undefined; + expect(machine).toBeTruthy(); + expect(machine?.active).toBe(false); + expect(machine?.activeAt).toBe(120); + expect(machine?.seq).toBe(7); + expect(machine?.metadata).toBeNull(); + expect(machine?.daemonState).toBeNull(); + }); +}); + +describe('socket update handling: machine-activity for unknown machine', () => { + beforeEach(() => { + storage.setState(initialStorageState, true); + }); + + it('routes update to addMachineActivityUpdate callback without directly writing to storage', () => { + const addMachineActivityUpdate = vi.fn(); + expect(storage.getState().machines['m_unknown']).toBeUndefined(); + + handleEphemeralSocketUpdate({ + update: { type: 'machine-activity', id: 'm_unknown', active: true, activeAt: 999 }, + addActivityUpdate: () => {}, + addMachineActivityUpdate, + }); + + expect(addMachineActivityUpdate).toHaveBeenCalledWith({ id: 'm_unknown', active: true, activeAt: 999 }); + expect(storage.getState().machines['m_unknown']).toBeUndefined(); + }); +}); + +describe('flushMachineActivityUpdates', () => { + beforeEach(() => { + storage.setState(initialStorageState, true); + }); + + it('applies a placeholder machine so active status is not dropped', () => { + const updates = new Map<string, { id: string; active: boolean; activeAt: number }>([ + ['m_unknown', { id: 'm_unknown', active: true, activeAt: 999 }], + ]); + const applyMachines = vi.fn((machines: Machine[]) => storage.getState().applyMachines(machines)); + + flushMachineActivityUpdates({ updates, applyMachines }); + + expect(applyMachines).toHaveBeenCalledTimes(1); + const machine = storage.getState().machines['m_unknown'] as Machine | undefined; + expect(machine).toBeTruthy(); + expect(machine?.active).toBe(true); + expect(machine?.activeAt).toBe(999); + }); +}); + diff --git a/apps/ui/sources/sync/engine/socket/socket.scmInvalidation.test.ts b/apps/ui/sources/sync/engine/socket/socket.scmInvalidation.test.ts index 7602e3200..e0b199852 100644 --- a/apps/ui/sources/sync/engine/socket/socket.scmInvalidation.test.ts +++ b/apps/ui/sources/sync/engine/socket/socket.scmInvalidation.test.ts @@ -81,6 +81,7 @@ describe('socket scm invalidation', () => { invalidateMessagesForSession: vi.fn(), assumeUsers: vi.fn(async () => {}), applyTodoSocketUpdates: vi.fn(async () => {}), + invalidateMachines: vi.fn(), invalidateSessions: vi.fn(), invalidateArtifacts: vi.fn(), invalidateFriends: vi.fn(), @@ -94,4 +95,3 @@ describe('socket scm invalidation', () => { expect(invalidateScmSpy).not.toHaveBeenCalled(); }); }); - diff --git a/apps/ui/sources/sync/engine/socket/socket.ts b/apps/ui/sources/sync/engine/socket/socket.ts index 3418e0216..feb9ea1c1 100644 --- a/apps/ui/sources/sync/engine/socket/socket.ts +++ b/apps/ui/sources/sync/engine/socket/socket.ts @@ -3,6 +3,7 @@ import type { Encryption } from '@/sync/encryption/encryption'; import type { NormalizedMessage } from '@/sync/typesRaw'; import type { Session } from '@/sync/domains/state/storageTypes'; import type { Machine } from '@/sync/domains/state/storageTypes'; +import type { MachineActivityUpdate } from '@/sync/reducer/machineActivityAccumulator'; import { storage } from '@/sync/domains/state/storage'; import { projectManager } from '@/sync/runtime/orchestration/projectManager'; import { scmStatusSync } from '@/scm/scmStatusSync'; @@ -52,6 +53,7 @@ export async function handleSocketUpdate(params: { invalidateMessagesForSession: (sessionId: string) => void; assumeUsers: (userIds: string[]) => Promise<void>; applyTodoSocketUpdates: (changes: any[]) => Promise<void>; + invalidateMachines: () => void; invalidateSessions: () => void; invalidateArtifacts: () => void; invalidateFriends: () => void; @@ -76,6 +78,7 @@ export async function handleSocketUpdate(params: { invalidateMessagesForSession, assumeUsers, applyTodoSocketUpdates, + invalidateMachines, invalidateSessions, invalidateArtifacts, invalidateFriends, @@ -104,6 +107,7 @@ export async function handleSocketUpdate(params: { invalidateMessagesForSession, assumeUsers, applyTodoSocketUpdates, + invalidateMachines, invalidateSessions, invalidateArtifacts, invalidateFriends, @@ -130,6 +134,7 @@ export async function handleUpdateContainer(params: { invalidateMessagesForSession: (sessionId: string) => void; assumeUsers: (userIds: string[]) => Promise<void>; applyTodoSocketUpdates: (changes: any[]) => Promise<void>; + invalidateMachines: () => void; invalidateSessions: () => void; invalidateArtifacts: () => void; invalidateFriends: () => void; @@ -154,6 +159,7 @@ export async function handleUpdateContainer(params: { invalidateMessagesForSession, assumeUsers, applyTodoSocketUpdates, + invalidateMachines, invalidateSessions, invalidateArtifacts, invalidateFriends, @@ -266,6 +272,29 @@ export async function handleUpdateContainer(params: { getLocalSettings: () => storage.getState().settings, log, }); + } else if (updateData.body.t === 'new-machine') { + log.log('🖥️ New machine update received'); + const machineUpdate = updateData.body; + const machineId = machineUpdate.machineId; + + // Apply a placeholder immediately so UI state (e.g. onboarding) can react + // even if machine-activity ephemerals arrive before a full machines refresh. + storage.getState().applyMachines([{ + id: machineId, + seq: machineUpdate.seq, + createdAt: machineUpdate.createdAt, + updatedAt: machineUpdate.updatedAt, + active: machineUpdate.active, + activeAt: machineUpdate.activeAt, + revokedAt: null, + metadata: null, + metadataVersion: machineUpdate.metadataVersion, + daemonState: null, + daemonStateVersion: machineUpdate.daemonStateVersion, + }]); + + // Hydrate machine details + encryption keys via the existing machines sync pipeline. + invalidateMachines(); } else if (updateData.body.t === 'update-machine') { const machineUpdate = updateData.body; const machineId = machineUpdate.machineId; // Changed from .id to .machineId @@ -426,11 +455,42 @@ export function flushActivityUpdates(params: { updates: Map<string, ApiEphemeral } } +export function flushMachineActivityUpdates(params: { + updates: Map<string, MachineActivityUpdate>; + applyMachines: (machines: Machine[]) => void; +}): void { + const { updates, applyMachines } = params; + const machines: Machine[] = []; + + for (const [, updateData] of updates) { + const existing = storage.getState().machines[updateData.id]; + const machine: Machine = existing ?? { + id: updateData.id, + seq: 0, + createdAt: updateData.activeAt, + updatedAt: updateData.activeAt, + active: updateData.active, + activeAt: updateData.activeAt, + revokedAt: null, + metadata: null, + metadataVersion: 0, + daemonState: null, + daemonStateVersion: 0, + }; + machines.push(buildMachineFromMachineActivityEphemeralUpdate({ machine, updateData })); + } + + if (machines.length > 0) { + applyMachines(machines); + } +} + export function handleEphemeralSocketUpdate(params: { update: unknown; - addActivityUpdate: (update: any) => void; + addActivityUpdate: (update: ApiEphemeralActivityUpdate) => void; + addMachineActivityUpdate: (update: MachineActivityUpdate) => void; }): void { - const { update, addActivityUpdate } = params; + const { update, addActivityUpdate, addMachineActivityUpdate } = params; const updateData = parseEphemeralUpdate(update); if (!updateData) return; @@ -438,16 +498,9 @@ export function handleEphemeralSocketUpdate(params: { // Process activity updates through smart debounce accumulator if (updateData.type === 'activity') { addActivityUpdate(updateData); - } - - // Handle machine activity updates - if (updateData.type === 'machine-activity') { - // Update machine's active status and lastActiveAt - const machine = storage.getState().machines[updateData.id]; - if (machine) { - const updatedMachine: Machine = buildMachineFromMachineActivityEphemeralUpdate({ machine, updateData }); - storage.getState().applyMachines([updatedMachine]); - } + } else if (updateData.type === 'machine-activity') { + // Handle machine activity updates through batching accumulator + addMachineActivityUpdate({ id: updateData.id, active: updateData.active, activeAt: updateData.activeAt }); } // daemon-status ephemeral updates are deprecated, machine status is handled via machine-activity diff --git a/apps/ui/sources/sync/ops/__tests__/saplingRepoHarness.ts b/apps/ui/sources/sync/ops/__tests__/saplingRepoHarness.ts index 8500acf27..430bfa9e5 100644 --- a/apps/ui/sources/sync/ops/__tests__/saplingRepoHarness.ts +++ b/apps/ui/sources/sync/ops/__tests__/saplingRepoHarness.ts @@ -12,6 +12,15 @@ import { } from '@happier-dev/protocol'; import { RPC_METHODS } from '@happier-dev/protocol/rpc'; +const SAPLING_TEST_ENV: NodeJS.ProcessEnv = { + ...process.env, + NODE_ENV: process.env.NODE_ENV ?? 'test', + // Ensure integration tests do not accidentally pick up user/system Sapling/Mercurial config + // (extensions, hooks, remotes, credential helpers) that can make commands slow/flaky. + // Repo-local config (set via `sl config --local …`) is still read. + HGRCPATH: '/dev/null', +}; + type SaplingStatusEntry = { path: string; kind: 'modified' | 'added' | 'deleted' | 'untracked' | 'conflicted'; @@ -142,6 +151,7 @@ function buildSnapshot(cwd: string) { export function runSapling(cwd: string, args: string[]): string { return execFileSync('sl', args, { cwd, + env: SAPLING_TEST_ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim(); @@ -154,6 +164,7 @@ function runSaplingResult( try { const stdout = execFileSync('sl', args, { cwd, + env: SAPLING_TEST_ENV, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); diff --git a/apps/ui/sources/sync/ops/sessionScm.test.ts b/apps/ui/sources/sync/ops/sessionScm.test.ts index e389fc0be..8f7dcfe66 100644 --- a/apps/ui/sources/sync/ops/sessionScm.test.ts +++ b/apps/ui/sources/sync/ops/sessionScm.test.ts @@ -38,7 +38,7 @@ describe('sessionScm', () => { expect(response.success).toBe(false); expect(response.errorCode).toBe(SCM_OPERATION_ERROR_CODES.FEATURE_UNSUPPORTED); - expect(response.error).toBe('RPC method not available'); + expect(response.error).toBe(RPC_ERROR_MESSAGES.METHOD_NOT_FOUND); }); it('applies sapling backend preference when configured', async () => { @@ -83,7 +83,7 @@ describe('sessionScm', () => { const response = await sessionScmStatusSnapshot('session-1', {}); expect(response.success).toBe(false); - expect(response.errorCode).toBe(SCM_OPERATION_ERROR_CODES.FEATURE_UNSUPPORTED); + expect(response.errorCode).toBe(SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE); expect(response.error).toBe(RPC_ERROR_MESSAGES.METHOD_NOT_AVAILABLE); }); @@ -104,6 +104,6 @@ describe('sessionScm', () => { expect(response.success).toBe(false); expect(response.errorCode).toBe(SCM_OPERATION_ERROR_CODES.FEATURE_UNSUPPORTED); - expect(response.error).toBe(RPC_ERROR_MESSAGES.METHOD_NOT_AVAILABLE); + expect(response.error).toBe(RPC_ERROR_MESSAGES.METHOD_NOT_FOUND); }); }); diff --git a/apps/ui/sources/sync/ops/sessionScm.ts b/apps/ui/sources/sync/ops/sessionScm.ts index 5c1f10b09..888c1f560 100644 --- a/apps/ui/sources/sync/ops/sessionScm.ts +++ b/apps/ui/sources/sync/ops/sessionScm.ts @@ -29,7 +29,7 @@ function scmFallbackError<T extends { success: boolean; error?: string; errorCod if (error instanceof Error && error.message === SCM_UNSUPPORTED_RESPONSE_ERROR) { return { success: false, - error: RPC_ERROR_MESSAGES.METHOD_NOT_AVAILABLE, + error: RPC_ERROR_MESSAGES.METHOD_NOT_FOUND, errorCode: SCM_OPERATION_ERROR_CODES.FEATURE_UNSUPPORTED, } as T; } @@ -45,10 +45,17 @@ function scmFallbackError<T extends { success: boolean; error?: string; errorCod : undefined, }; - if (isRpcMethodNotAvailableError(rpcError) || isRpcMethodNotFoundError(rpcError)) { + if (isRpcMethodNotAvailableError(rpcError)) { return { success: false, error: RPC_ERROR_MESSAGES.METHOD_NOT_AVAILABLE, + errorCode: SCM_OPERATION_ERROR_CODES.BACKEND_UNAVAILABLE, + } as T; + } + if (isRpcMethodNotFoundError(rpcError)) { + return { + success: false, + error: RPC_ERROR_MESSAGES.METHOD_NOT_FOUND, errorCode: SCM_OPERATION_ERROR_CODES.FEATURE_UNSUPPORTED, } as T; } diff --git a/apps/ui/sources/sync/reducer/machineActivityAccumulator.ts b/apps/ui/sources/sync/reducer/machineActivityAccumulator.ts new file mode 100644 index 000000000..652dd1083 --- /dev/null +++ b/apps/ui/sources/sync/reducer/machineActivityAccumulator.ts @@ -0,0 +1,66 @@ +export type MachineActivityUpdate = { id: string; active: boolean; activeAt: number }; + +export class MachineActivityAccumulator { + private pendingUpdates = new Map<string, MachineActivityUpdate>(); + private lastEmittedStates = new Map<string, { active: boolean; activeAt: number }>(); + private timeoutId: ReturnType<typeof setTimeout> | null = null; + + constructor( + private flushHandler: (updates: Map<string, MachineActivityUpdate>) => void, + private debounceDelay: number = 300 + ) {} + + addUpdate(update: MachineActivityUpdate): void { + const lastState = this.lastEmittedStates.get(update.id); + const isSignificantChange = !lastState || lastState.active !== update.active; + this.pendingUpdates.set(update.id, update); + + if (isSignificantChange) { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + this.flushPendingUpdates(); + } else if (!this.timeoutId) { + this.timeoutId = setTimeout(() => { + this.flushPendingUpdates(); + this.timeoutId = null; + }, this.debounceDelay); + } + } + + private flushPendingUpdates(): void { + if (this.pendingUpdates.size > 0) { + const updatesToFlush = new Map(this.pendingUpdates); + this.flushHandler(updatesToFlush); + for (const [id, update] of updatesToFlush) { + this.lastEmittedStates.set(id, { active: update.active, activeAt: update.activeAt }); + } + this.pendingUpdates.clear(); + } + } + + cancel(): void { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + // Pending updates are intentionally dropped without flushing. + // Only safe when the corresponding storage state is also being discarded + // (e.g. via resetServerScopedRuntimeState). + this.pendingUpdates.clear(); + } + + reset(): void { + this.cancel(); + this.lastEmittedStates.clear(); + } + + flush(): void { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + this.flushPendingUpdates(); + } +} diff --git a/apps/ui/sources/sync/reducer/userAndText.streaming.providerAgnostic.spec.ts b/apps/ui/sources/sync/reducer/userAndText.streaming.providerAgnostic.spec.ts index 6981e0808..55b102e02 100644 --- a/apps/ui/sources/sync/reducer/userAndText.streaming.providerAgnostic.spec.ts +++ b/apps/ui/sources/sync/reducer/userAndText.streaming.providerAgnostic.spec.ts @@ -67,7 +67,7 @@ describe('runUserAndTextPhase (streaming merge)', () => { isSidechain: false, role: 'agent', content: [{ type: 'text', text: 'Hello', uuid: 'u1', parentUUID: null }], - meta: null, + meta: undefined, }, { id: 'agent_msg_1', @@ -76,7 +76,7 @@ describe('runUserAndTextPhase (streaming merge)', () => { isSidechain: false, role: 'agent', content: [{ type: 'text', text: ' world', uuid: 'u2', parentUUID: 'u1' }], - meta: null, + meta: undefined, }, ], changed, diff --git a/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/resolveScopedSessionDataKey.test.ts b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/resolveScopedSessionDataKey.test.ts new file mode 100644 index 000000000..64ad521a1 --- /dev/null +++ b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/resolveScopedSessionDataKey.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { resetScopedSessionDataKeyCacheForTests, resolveScopedSessionDataKey } from './resolveScopedSessionDataKey'; + +const runtimeFetchMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/utils/system/runtimeFetch', () => ({ + runtimeFetch: (...args: unknown[]) => runtimeFetchMock(...args), +})); + +const validSessionById = { + id: 'session-1', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + archivedAt: null, + metadata: 'metadata', + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: 'k1', +}; + +describe('resolveScopedSessionDataKey', () => { + afterEach(() => { + runtimeFetchMock.mockReset(); + resetScopedSessionDataKeyCacheForTests(); + }); + + it('loads and decrypts the session data encryption key from a valid by-id response', async () => { + runtimeFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ session: validSessionById }), + }); + const decrypt = vi.fn(async () => new Uint8Array([9, 9])); + + const key = await resolveScopedSessionDataKey({ + serverId: 's-id', + serverUrl: 'https://server.example.test', + token: 'token', + sessionId: 'session-1', + decryptEncryptionKey: decrypt, + }); + + expect(runtimeFetchMock).toHaveBeenCalledTimes(1); + expect(decrypt).toHaveBeenCalledWith('k1'); + expect(key).toEqual(new Uint8Array([9, 9])); + }); + + it('returns null and does not call decryption for an invalid by-id shape', async () => { + runtimeFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ session: { id: 'session-1', dataEncryptionKey: 'k1' } }), + }); + + const decrypt = vi.fn(async () => new Uint8Array([9])); + + const key = await resolveScopedSessionDataKey({ + serverId: 's-id', + serverUrl: 'https://server.example.test', + token: 'token', + sessionId: 'session-1', + decryptEncryptionKey: decrypt, + timeoutMs: 10, + }); + + expect(key).toBeNull(); + expect(decrypt).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/resolveScopedSessionDataKey.ts b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/resolveScopedSessionDataKey.ts index 2cf404f4b..613e8a3a2 100644 --- a/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/resolveScopedSessionDataKey.ts +++ b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/resolveScopedSessionDataKey.ts @@ -1,3 +1,4 @@ +import { type V2SessionByIdResponse, V2SessionByIdResponseSchema } from '@happier-dev/protocol'; import { runtimeFetch } from '@/utils/system/runtimeFetch'; function normalizeId(raw: unknown): string { @@ -75,8 +76,10 @@ async function fetchSessionDataKey(params: Readonly<{ }); if (!response.ok) return null; - const body = (await response.json()) as { session?: { id: string; dataEncryptionKey?: string | null } }; - const session = body?.session ?? null; + const body = (await response.json()) as unknown; + const parsed = V2SessionByIdResponseSchema.safeParse(body); + if (!parsed.success) return null; + const session: V2SessionByIdResponse['session'] = parsed.data.session; if (!session) return null; if (normalizeId(session.id) !== params.sessionId) return null; const dek = typeof session.dataEncryptionKey === 'string' ? session.dataEncryptionKey : null; diff --git a/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionRpc.test.ts b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionRpc.test.ts index 396926ce1..b1d22fe38 100644 --- a/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionRpc.test.ts +++ b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionRpc.test.ts @@ -4,6 +4,23 @@ import { SOCKET_RPC_EVENTS } from '@happier-dev/protocol/socketRpc'; import { resetScopedSessionDataKeyCacheForTests } from './resolveScopedSessionDataKey'; +const sessionListByIdFixture = { + id: 'session-1', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: false, + activeAt: 1, + archivedAt: null, + metadata: 'metadata', + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 0, + dataEncryptionKey: 'k1', +} as const; + const ioSpy = vi.hoisted(() => vi.fn()); const sessionRpcSpy = vi.hoisted(() => vi.fn()); const getCredentialsSpy = vi.hoisted(() => vi.fn()); @@ -97,7 +114,7 @@ describe('sessionRpcWithServerScope', () => { 'fetch', vi.fn(async () => ({ ok: true, - json: async () => ({ session: { id: 'session-1', dataEncryptionKey: 'k1' } }), + json: async () => ({ session: sessionListByIdFixture }), })), ); diff --git a/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionSendMessage.test.ts b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionSendMessage.test.ts index 327a0b650..145be82ae 100644 --- a/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionSendMessage.test.ts +++ b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionSendMessage.test.ts @@ -1,16 +1,22 @@ import { describe, expect, it, vi } from 'vitest'; +const state = { + sessions: { + s1: { id: 's1', permissionMode: 'default', metadata: { flavor: 'claude' }, modelMode: 'default' }, + }, + settings: {}, +}; + vi.mock('@/sync/domains/state/storage', () => ({ storage: { - getState: () => ({ - sessions: { s1: { id: 's1', permissionMode: 'default', metadata: { flavor: 'claude' }, modelMode: 'default' } }, - settings: {}, - }), + getState: () => state, }, })); describe('sendSessionMessageWithServerScope', () => { it('uses scoped socket path when context is scoped', async () => { + state.sessions.s1 = { id: 's1', permissionMode: 'default', metadata: { flavor: 'claude' }, modelMode: 'default' }; + const { createServerScopedSessionSendMessage } = await import('./serverScopedSessionSendMessage'); const emitWithAck = vi.fn(async () => ({ ok: true, id: 'm1', seq: 1, localId: null })); @@ -49,4 +55,53 @@ describe('sendSessionMessageWithServerScope', () => { expect(getScopedSessionEncryption).toHaveBeenCalled(); expect(emitWithAck).toHaveBeenCalledWith('message', expect.objectContaining({ sid: 's1', message: 'encrypted_record' })); }); + + it('sends plaintext envelopes for scoped sessions when session encryptionMode is plain', async () => { + state.sessions.s1 = { + id: 's1', + encryptionMode: 'plain', + permissionMode: 'default', + metadata: { flavor: 'claude' }, + modelMode: 'default', + } as any; + + const { createServerScopedSessionSendMessage } = await import('./serverScopedSessionSendMessage'); + + const emitWithAck = vi.fn(async () => ({ ok: true, id: 'm1', seq: 1, localId: null })); + const socket = { + timeout: (_ms: number) => ({ emitWithAck }), + disconnect: vi.fn(), + }; + + const sendMessageActive = vi.fn(async () => {}); + const getScopedSessionEncryption = vi.fn(async () => ({ + encryptRawRecord: async () => 'encrypted_record', + })); + + const resolveContext = vi.fn(async () => ({ + scope: 'scoped' as const, + timeoutMs: 1000, + targetServerId: 'server-b', + targetServerUrl: 'https://server-b.example', + token: 't1', + encryption: {} as any, + })); + + const createSocket = vi.fn(async () => socket as any); + + const { sendSessionMessageWithServerScope } = createServerScopedSessionSendMessage({ + resolveContext: resolveContext as any, + createSocket, + getScopedSessionEncryption, + sendMessageActive, + }); + + const res = await sendSessionMessageWithServerScope({ sessionId: 's1', message: 'hello', serverId: 'server-b', timeoutMs: 1000 }); + expect(res.ok).toBe(true); + expect(getScopedSessionEncryption).not.toHaveBeenCalled(); + expect(emitWithAck).toHaveBeenCalledWith( + 'message', + expect.objectContaining({ sid: 's1', message: expect.objectContaining({ t: 'plain', v: expect.any(Object) }) }), + ); + }); }); diff --git a/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionSendMessage.ts b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionSendMessage.ts index e00fb93ba..c67c60210 100644 --- a/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionSendMessage.ts +++ b/apps/ui/sources/sync/runtime/orchestration/serverScopedRpc/serverScopedSessionSendMessage.ts @@ -114,6 +114,7 @@ export function createServerScopedSessionSendMessage(deps?: Partial<Deps>): Read if (!session) { return { ok: false, errorCode: 'session_not_found', error: 'session_not_found' }; } + const sessionEncryptionMode: 'e2ee' | 'plain' = session.encryptionMode === 'plain' ? 'plain' : 'e2ee'; const permissionMode = (session.permissionMode || 'default') as string; const flavor = session.metadata?.flavor; @@ -143,13 +144,18 @@ export function createServerScopedSessionSendMessage(deps?: Partial<Deps>): Read meta, }; - const sessionEncryption = await d.getScopedSessionEncryption({ context, sessionId }); - const encryptedRawRecord = await sessionEncryption.encryptRawRecord(record); + const messagePayload = + sessionEncryptionMode === 'plain' + ? { t: 'plain' as const, v: record } + : await (async () => { + const sessionEncryption = await d.getScopedSessionEncryption({ context, sessionId }); + return await sessionEncryption.encryptRawRecord(record); + })(); const localId = randomUUID(); const payload = { sid: sessionId, - message: encryptedRawRecord, + message: messagePayload, localId, sentFrom, permissionMode: permissionMode || 'default', diff --git a/apps/ui/sources/sync/sync.create.initialAwaitTimeout.test.ts b/apps/ui/sources/sync/sync.create.initialAwaitTimeout.test.ts new file mode 100644 index 000000000..10c69aae4 --- /dev/null +++ b/apps/ui/sources/sync/sync.create.initialAwaitTimeout.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Sync imports persistence, which instantiates MMKV. Mock it for deterministic tests. +const kvStore = vi.hoisted(() => new Map<string, string>()); +vi.mock('react-native-mmkv', () => { + class MMKV { + getString(key: string) { + return kvStore.get(key); + } + set(key: string, value: string) { + kvStore.set(key, value); + } + delete(key: string) { + kvStore.delete(key); + } + clearAll() { + kvStore.clear(); + } + } + + return { MMKV }; +}); + +const appStateAddListener = vi.hoisted(() => vi.fn(() => ({ remove: vi.fn() }))); +vi.mock('react-native', async () => { + const actual = await vi.importActual<any>('react-native'); + return { + ...actual, + Platform: { ...(actual?.Platform ?? {}), OS: 'web' }, + AppState: { addEventListener: appStateAddListener as any }, + }; +}); + +vi.mock('@/log', () => ({ + log: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@/voice/context/voiceHooks', () => ({ + voiceHooks: { + onSessionFocus: vi.fn(), + onSessionOffline: vi.fn(), + onSessionOnline: vi.fn(), + onMessages: vi.fn(), + reportContextualUpdate: vi.fn(), + }, +})); + +vi.mock('@/track', () => ({ + initializeTracking: vi.fn(), + tracking: null, + trackPaywallPresented: vi.fn(), + trackPaywallPurchased: vi.fn(), + trackPaywallCancelled: vi.fn(), + trackPaywallRestored: vi.fn(), + trackPaywallError: vi.fn(), +})); + +import type { AuthCredentials } from '@/auth/storage/tokenStorage'; +import { TokenStorage } from '@/auth/storage/tokenStorage'; +import { encodeBase64 } from '@/encryption/base64'; +import { encodeUTF8 } from '@/encryption/text'; +import { Encryption } from '@/sync/encryption/encryption'; +import { upsertAndActivateServer } from '@/sync/domains/server/serverRuntime'; + +function buildTokenWithSub(sub: string): string { + const payload = encodeBase64(encodeUTF8(JSON.stringify({ sub })), 'base64'); + return `hdr.${payload}.sig`; +} + +function installLocalStorage(): void { + if (typeof (globalThis as any).localStorage !== 'undefined') return; + + const store = new Map<string, string>(); + (globalThis as any).localStorage = { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + return store.has(key) ? store.get(key)! : null; + }, + key(index: number) { + const keys = [...store.keys()]; + return typeof keys[index] === 'string' ? keys[index] : null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(String(key), String(value)); + }, + }; +} + +describe('sync.create initial awaits', () => { + beforeEach(() => { + vi.useFakeTimers(); + kvStore.clear(); + appStateAddListener.mockClear(); + installLocalStorage(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('does not hang forever waiting for initial sync queues', async () => { + // Simulate a network stall: fetch never resolves. + vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(() => {}))); + + upsertAndActivateServer({ serverUrl: 'http://localhost:53288', scope: 'tab' }); + + const encryption = await Encryption.create(new Uint8Array(32).fill(9)); + const { sync } = await import('./sync'); + + const credentials: AuthCredentials = { + token: buildTokenWithSub('server-test'), + secret: encodeBase64(new Uint8Array(32).fill(7), 'base64url'), + }; + + await TokenStorage.setCredentials(credentials); + + let resolved = false; + const promise = sync.create(credentials, encryption).then(() => { + resolved = true; + }); + + // Current behavior (pre-fix) hangs forever; expected behavior resolves via awaitQueue timeouts. + await vi.advanceTimersByTimeAsync(10_000); + expect(resolved).toBe(true); + + await promise; + }); +}); diff --git a/apps/ui/sources/sync/sync.optimisticThinking.test.ts b/apps/ui/sources/sync/sync.optimisticThinking.test.ts index fe6caf907..6a3459fac 100644 --- a/apps/ui/sources/sync/sync.optimisticThinking.test.ts +++ b/apps/ui/sources/sync/sync.optimisticThinking.test.ts @@ -117,6 +117,45 @@ describe('sync.sendMessage optimistic thinking', () => { expect(storage.getState().sessions[sessionId].optimisticThinkingAt ?? null).toBeNull(); }); + it('sends plaintext message envelopes when session encryptionMode is plain', async () => { + const sessionId = 's_plain_send'; + storage.getState().applySessions([{ ...createSession({ sessionId }), encryptionMode: 'plain' }]); + + const encryptRawRecord = vi.fn(async () => { + throw new Error('encryptRawRecord should not be called') + }) + const encryption = { + getSessionEncryption: () => ({ encryptRawRecord }), + } as unknown as Encryption; + + const emitWithAck = vi.fn(async () => ({ + ok: true, + id: 'm1', + seq: 1, + localId: null, + didWrite: true, + })) as any; + + const { sync } = await import('./sync'); + sync.encryption = encryption as any; + sync.setMessageTransport({ + emitWithAck, + send: vi.fn(), + }); + + await sync.sendMessage(sessionId, 'hello'); + + expect(encryptRawRecord).not.toHaveBeenCalled(); + expect(emitWithAck).toHaveBeenCalledWith( + 'message', + expect.objectContaining({ + sid: sessionId, + message: expect.objectContaining({ t: 'plain', v: expect.any(Object) }), + }), + expect.anything(), + ); + }); + it('includes metaOverrides (e.g. meta.happier) in the outbound rawRecord meta', async () => { const sessionId = 's_meta_overrides'; storage.getState().applySessions([createSession({ sessionId })]); diff --git a/apps/ui/sources/sync/sync.ts b/apps/ui/sources/sync/sync.ts index 788769fba..7560fe8ec 100644 --- a/apps/ui/sources/sync/sync.ts +++ b/apps/ui/sources/sync/sync.ts @@ -7,9 +7,10 @@ import { decodeBase64, encodeBase64 } from '@/encryption/base64'; import { storage } from './domains/state/storage'; import { ApiMessage } from './api/types/apiTypes'; import type { ApiEphemeralActivityUpdate } from './api/types/apiTypes'; -import { Session, Machine, type Metadata } from './domains/state/storageTypes'; +import { Session, Machine, MetadataSchema, type Metadata } from './domains/state/storageTypes'; import { InvalidateSync } from '@/utils/sessions/sync'; import { ActivityUpdateAccumulator } from './reducer/activityUpdateAccumulator'; +import { MachineActivityAccumulator, type MachineActivityUpdate } from './reducer/machineActivityAccumulator'; import { randomUUID } from '@/platform/randomUUID'; import { Platform, AppState } from 'react-native'; import { resolveSentFrom } from './domains/messages/sentFrom'; @@ -120,6 +121,7 @@ import { } from './engine/pending/pendingQueueV2'; import { flushActivityUpdates as flushActivityUpdatesEngine, + flushMachineActivityUpdates as flushMachineActivityUpdatesEngine, handleEphemeralSocketUpdate, handleSocketReconnected, handleSocketUpdate, @@ -179,6 +181,7 @@ class Sync { private todosSync: InvalidateSync; private automationsSync: InvalidateSync; private activityAccumulator: ActivityUpdateAccumulator; + private machineActivityAccumulator!: MachineActivityAccumulator; private pendingSettings: Partial<Settings> = loadPendingSettings(); private pendingSettingsFlushTimer: ReturnType<typeof setTimeout> | null = null; private pendingSettingsDirty = false; @@ -244,24 +247,27 @@ class Sync { } this.pushTokenSync = new InvalidateSync(registerPushToken); this.activityAccumulator = new ActivityUpdateAccumulator(this.flushActivityUpdates.bind(this), 500); + this.machineActivityAccumulator = new MachineActivityAccumulator(this.flushMachineActivityUpdates.bind(this), 300); // Listen for app state changes to refresh purchases AppState.addEventListener('change', (nextAppState) => { if (nextAppState === 'active') { log.log('📱 App became active'); - this.purchasesSync.invalidate(); - this.profileSync.invalidate(); - this.machinesSync.invalidate(); - this.pushTokenSync.invalidate(); - this.sessionsSync.invalidate(); - this.nativeUpdateSync.invalidate(); + // Many devices/platforms can emit multiple "active" transitions in quick succession. + // Coalesce invalidations to avoid redundant double-runs that can spike API load. + this.purchasesSync.invalidateCoalesced(); + this.profileSync.invalidateCoalesced(); + this.machinesSync.invalidateCoalesced(); + this.pushTokenSync.invalidateCoalesced(); + this.sessionsSync.invalidateCoalesced(); + this.nativeUpdateSync.invalidateCoalesced(); log.log('📱 App became active: Invalidating artifacts sync'); - this.artifactsSync.invalidate(); - this.friendsSync.invalidate(); - this.friendRequestsSync.invalidate(); - this.feedSync.invalidate(); - this.todosSync.invalidate(); - this.automationsSync.invalidate(); + this.artifactsSync.invalidateCoalesced(); + this.friendsSync.invalidateCoalesced(); + this.friendRequestsSync.invalidateCoalesced(); + this.feedSync.invalidateCoalesced(); + this.todosSync.invalidateCoalesced(); + this.automationsSync.invalidateCoalesced(); } else { log.log(`📱 App state changed to: ${nextAppState}`); // Reliability: ensure we persist any pending settings immediately when backgrounding. @@ -345,14 +351,14 @@ class Sync { } await this.#init(); - // Await settings sync to have fresh settings - await this.settingsSync.awaitQueue(); - - // Await profile sync to have fresh profile - await this.profileSync.awaitQueue(); - - // Await purchases sync to have fresh purchases - await this.purchasesSync.awaitQueue(); + // UX: avoid blocking login forever if initial sync fetches hang/retry indefinitely. + // We still kick off the sync work in #init(); this just bounds the time we block the login call. + const initialAwaitTimeoutMs = 2500; + await Promise.all([ + this.settingsSync.awaitQueue({ timeoutMs: initialAwaitTimeoutMs }), + this.profileSync.awaitQueue({ timeoutMs: initialAwaitTimeoutMs }), + this.purchasesSync.awaitQueue({ timeoutMs: initialAwaitTimeoutMs }), + ]); } async restore(credentials: AuthCredentials, encryption: Encryption) { @@ -380,6 +386,7 @@ class Sync { private resetServerScopedRuntimeState = () => { apiSocket.disconnect(); this.activityAccumulator.reset(); + this.machineActivityAccumulator.reset(); for (const timer of this.pendingMessageCommitRetryTimers.values()) { clearTimeout(timer); @@ -543,14 +550,6 @@ class Sync { async sendMessage(sessionId: string, text: string, displayText?: string, metaOverrides?: Record<string, unknown>) { storage.getState().markSessionOptimisticThinking(sessionId); - // Get encryption - const encryption = this.encryption.getSessionEncryption(sessionId); - if (!encryption) { // Should never happen - storage.getState().clearSessionOptimisticThinking(sessionId); - console.error(`Session ${sessionId} not found`); - return; - } - // Get session data from storage const session = storage.getState().sessions[sessionId]; if (!session) { @@ -559,6 +558,8 @@ class Sync { return; } + const sessionEncryptionMode: 'e2ee' | 'plain' = session.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + try { // Read permission mode from session state const permissionMode = session.permissionMode || 'default'; @@ -594,7 +595,17 @@ class Sync { metaOverrides: metaOverrides as any, }) }; - const encryptedRawRecord = await encryption.encryptRawRecord(content); + + const messagePayload = + sessionEncryptionMode === 'plain' + ? { t: 'plain' as const, v: content } + : await (async () => { + const encryption = this.encryption.getSessionEncryption(sessionId); + if (!encryption) { + throw new Error(`Session ${sessionId} encryption not found`); + } + return await encryption.encryptRawRecord(content); + })(); // Track this outbound user message in the local pending queue until it is committed. // This prevents “ghost” optimistic transcript items when the send fails, and it lets the UI @@ -617,7 +628,7 @@ class Sync { const payload = { sid: sessionId, - message: encryptedRawRecord, + message: messagePayload, localId, sentFrom, permissionMode: permissionMode || 'default' @@ -858,8 +869,10 @@ class Sync { } private async updateSessionMetadataWithRetry(sessionId: string, updater: (metadata: Metadata) => Metadata): Promise<void> { - const encryption = this.encryption.getSessionEncryption(sessionId); - if (!encryption) { + const session = storage.getState().sessions[sessionId] ?? null; + const sessionEncryptionMode: 'e2ee' | 'plain' = session?.encryptionMode === 'plain' ? 'plain' : 'e2ee'; + const encryption = sessionEncryptionMode === 'plain' ? null : this.encryption.getSessionEncryption(sessionId); + if (sessionEncryptionMode === 'e2ee' && !encryption) { throw new Error(`Session ${sessionId} not found`); } @@ -873,8 +886,24 @@ class Sync { refreshSessions: async () => { await this.refreshSessions(); }, - encryptMetadata: async (metadata) => encryption.encryptMetadata(metadata), - decryptMetadata: async (version, encrypted) => encryption.decryptMetadata(version, encrypted), + encryptMetadata: async (metadata) => { + if (sessionEncryptionMode === 'plain') { + return JSON.stringify(metadata); + } + return await encryption!.encryptMetadata(metadata); + }, + decryptMetadata: async (version, encrypted) => { + if (sessionEncryptionMode !== 'plain') { + return await encryption!.decryptMetadata(version, encrypted); + } + try { + const parsedJson = JSON.parse(encrypted); + const parsed = MetadataSchema.safeParse(parsedJson); + return parsed.success ? parsed.data : null; + } catch { + return null; + } + }, emitUpdateMetadata: async (payload) => apiSocket.emitWithAck<UpdateMetadataAck>('update-metadata', payload), applySessionMetadata: ({ metadataVersion, metadata }) => { const currentSession = storage.getState().sessions[sessionId]; @@ -1982,6 +2011,7 @@ class Sync { }, assumeUsers: (userIds) => this.assumeUsers(userIds), applyTodoSocketUpdates: (changes) => this.applyTodoSocketUpdates(changes), + invalidateMachines: () => this.machinesSync.invalidate(), invalidateSessions: () => this.sessionsSync.invalidate(), invalidateArtifacts: () => this.artifactsSync.invalidate(), invalidateFriends: () => this.friendsSync.invalidate(), @@ -1998,12 +2028,19 @@ class Sync { flushActivityUpdatesEngine({ updates, applySessions: (sessions) => this.applySessions(sessions) }); } + private flushMachineActivityUpdates = (updates: Map<string, MachineActivityUpdate>) => { + flushMachineActivityUpdatesEngine({ updates, applyMachines: (machines) => storage.getState().applyMachines(machines) }); + } + private handleEphemeralUpdate = (update: unknown) => { handleEphemeralSocketUpdate({ update, addActivityUpdate: (ephemeralUpdate) => { this.activityAccumulator.addUpdate(ephemeralUpdate); }, + addMachineActivityUpdate: (machineUpdate) => { + this.machineActivityAccumulator.addUpdate(machineUpdate); + }, }); } diff --git a/apps/ui/sources/text/translations/ca.ts b/apps/ui/sources/text/translations/ca.ts index fa23b937e..1a16aa707 100644 --- a/apps/ui/sources/text/translations/ca.ts +++ b/apps/ui/sources/text/translations/ca.ts @@ -131,6 +131,9 @@ export const ca: TranslationStructure = { openMachine: 'Obrir màquina', terminalUrlPlaceholder: 'happier://terminal?...', restoreQrInstructions: '1. Obre Happier al teu dispositiu mòbil\n2. Ves a Configuració → Compte\n3. Toca "Vincular nou dispositiu"\n4. Escaneja aquest codi QR', + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => `${provider} verificat`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `Hem trobat un compte de Happier existent vinculat a ${provider}. Per acabar d'iniciar sessió en aquest dispositiu, restaura la clau del teu compte amb el codi QR o amb la teva clau secreta.`, restoreWithSecretKeyInstead: 'Restaura amb clau secreta', restoreWithSecretKeyDescription: 'Introdueix la teva clau secreta per recuperar l’accés al teu compte.', lostAccessLink: 'Sense accés?', @@ -330,6 +333,19 @@ export const ca: TranslationStructure = { compactSessionViewDescription: 'Mostra les sessions actives en un disseny més compacte', compactSessionViewMinimal: 'Vista compacta mínima', compactSessionViewMinimalDescription: 'Amaga els avatars i mostra un disseny de fila de sessió molt compacte', + text: 'Text', + textDescription: 'Ajusta la mida del text a l’app', + textSize: 'Mida del text', + textSizeDescription: 'Fes el text més gran o més petit', + textSizeOptions: { + xxsmall: 'Molt molt petit', + xsmall: 'Molt petit', + small: 'Petit', + default: 'Per defecte', + large: 'Gran', + xlarge: 'Molt gran', + xxlarge: 'Molt molt gran', + }, }, settingsFeatures: { @@ -490,7 +506,7 @@ export const ca: TranslationStructure = { 'Per reprendre una conversa de Codex, instal·la el servidor de represa de Codex a la màquina de destinació (Detalls de la màquina → Represa de Codex).', codexAcpNotInstalledTitle: 'Codex ACP no està instal·lat en aquesta màquina', codexAcpNotInstalledMessage: - 'Per fer servir l’experiment de Codex ACP, instal·la codex-acp a la màquina de destinació (Detalls de la màquina → Codex ACP) o desactiva l’experiment.', + 'Per fer servir l’experiment de Codex ACP, instal·la codex-acp a la màquina de destinació (Detalls de la màquina → Installables) o desactiva l’experiment.', }, deps: { @@ -889,6 +905,8 @@ deps: { kiloSessionIdCopied: 'ID de la sessió de Kilo copiat al porta-retalls', piSessionId: 'ID de la sessio de Pi', piSessionIdCopied: 'ID de la sessio de Pi copiat al porta-retalls', + copilotSessionId: 'Copilot Session ID', + copilotSessionIdCopied: 'Copilot Session ID copied to clipboard', metadataCopied: 'Metadades copiades al porta-retalls', failedToCopyMetadata: 'Ha fallat copiar les metadades', failedToKillSession: 'Ha fallat finalitzar la sessió', @@ -1032,6 +1050,7 @@ deps: { kimi: 'Kimi', kilo: 'Kilo', pi: 'Pi', + copilot: 'Copilot', }, auggieIndexingChip: { on: 'Indexing on', @@ -1248,6 +1267,19 @@ deps: { noChanges: 'No hi ha canvis a mostrar', }, + settingsNotifications: { + foregroundBehavior: { + title: "Notificacions a l'app", + footer: "Controla les notificacions mentre fas servir l'app. Les notificacions de la sessió que estàs veient sempre se silencien.", + full: 'Completes', + fullDescription: 'Mostra el banner i reprodueix el so', + silent: 'Silencioses', + silentDescription: 'Mostra el banner sense so', + off: 'Desactivades', + offDescription: 'Només la insígnia, sense banner', + }, + }, + settingsSession: { messageSending: { title: 'Message sending', @@ -1768,6 +1800,12 @@ deps: { signUpWithProvider: ({ provider }: { provider: string }) => `Continua amb ${provider}`, linkOrRestoreAccount: 'Enllaça o restaura un compte', loginWithMobileApp: 'Inicia sessió amb l\'aplicació mòbil', + serverUnavailableTitle: 'No es pot connectar al servidor', + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `No podem connectar-nos a ${serverUrl}. Torna-ho a provar o canvia de servidor per continuar.`, + serverIncompatibleTitle: 'Servidor no compatible', + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `El servidor a ${serverUrl} ha retornat una resposta inesperada. Actualitza el servidor o canvia de servidor per continuar.`, }, review: { @@ -1783,11 +1821,11 @@ deps: { copiedToClipboard: ({ label }: { label: string }) => `${label} copiat al porta-retalls` }, - machine: { - offlineUnableToSpawn: 'El llançador està desactivat mentre la màquina està fora de línia', - offlineHelp: '• Assegura\'t que l\'ordinador estigui en línia\n• Executa `happier daemon status` per diagnosticar\n• Fas servir l\'última versió del CLI? Actualitza amb `npm install -g @happier-dev/cli@latest`', - launchNewSessionInDirectory: 'Inicia una nova sessió al directori', - daemon: 'Dimoni', + machine: { + offlineUnableToSpawn: 'El llançador està desactivat mentre la màquina està fora de línia', + offlineHelp: '• Assegura\'t que l\'ordinador estigui en línia\n• Executa `happier daemon status` per diagnosticar\n• Fas servir l\'última versió del CLI? Actualitza amb `npm install -g @happier-dev/cli@latest`', + launchNewSessionInDirectory: 'Inicia una nova sessió al directori', + daemon: 'Dimoni', status: 'Estat', stopDaemon: 'Atura el dimoni', stopDaemonConfirmTitle: 'Aturar el dimoni?', @@ -1795,14 +1833,20 @@ deps: { daemonStoppedTitle: 'Dimoni aturat', stopDaemonFailed: 'No s’ha pogut aturar el dimoni. Pot ser que no estigui en execució.', renameTitle: 'Canvia el nom de la màquina', - renameDescription: 'Dona a aquesta màquina un nom personalitzat. Deixa-ho buit per usar el hostname per defecte.', - renamePlaceholder: 'Introdueix el nom de la màquina', - renamedSuccess: 'Màquina reanomenada correctament', - renameFailed: 'No s’ha pogut reanomenar la màquina', - lastKnownPid: 'Últim PID conegut', - lastKnownHttpPort: 'Últim port HTTP conegut', - startedAt: 'Iniciat a', - cliVersion: 'Versió del CLI', + renameDescription: 'Dona a aquesta màquina un nom personalitzat. Deixa-ho buit per usar el hostname per defecte.', + renamePlaceholder: 'Introdueix el nom de la màquina', + renamedSuccess: 'Màquina reanomenada correctament', + renameFailed: 'No s’ha pogut reanomenar la màquina', + actions: { + removeMachine: 'Remove Machine', + removeMachineSubtitle: 'Revokes this machine and removes it from your account.', + removeMachineConfirmBody: 'This will revoke access from this machine (including access keys and automation assignments). You can reconnect later by signing in again from the CLI.', + removeMachineAlreadyRemoved: 'This machine has already been removed from your account.', + }, + lastKnownPid: 'Últim PID conegut', + lastKnownHttpPort: 'Últim port HTTP conegut', + startedAt: 'Iniciat a', + cliVersion: 'Versió del CLI', daemonStateVersion: 'Versió de l\'estat del dimoni', activeSessions: ({ count }: { count: number }) => `Sessions actives (${count})`, machineGroup: 'Màquina', @@ -2188,6 +2232,7 @@ deps: { kimiSubtitleExperimental: 'CLI de Kimi (experimental)', kiloSubtitleExperimental: 'CLI de Kilo (experimental)', piSubtitleExperimental: 'CLI de Pi (experimental)', + copilotSubtitleExperimental: 'GitHub Copilot CLI (experimental)', }, tmux: { title: 'Tmux', diff --git a/apps/ui/sources/text/translations/en.ts b/apps/ui/sources/text/translations/en.ts index 25144abbf..73f96d652 100644 --- a/apps/ui/sources/text/translations/en.ts +++ b/apps/ui/sources/text/translations/en.ts @@ -144,6 +144,9 @@ export const en = { openMachine: 'Open machine', terminalUrlPlaceholder: 'happier://terminal?...', restoreQrInstructions: '1. Open Happier on your mobile device\n2. Go to Settings → Account\n3. Tap "Link New Device"\n4. Scan this QR code', + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => `${provider} verified`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `We found an existing Happier account linked to ${provider}. To finish signing in on this device, restore your account key using the QR code or your secret key.`, restoreWithSecretKeyInstead: 'Restore with Secret Key Instead', restoreWithSecretKeyDescription: 'Enter your secret key to restore access to your account.', lostAccessLink: 'Lost access?', @@ -343,6 +346,19 @@ export const en = { compactSessionViewDescription: 'Show active sessions in a more compact layout', compactSessionViewMinimal: 'Minimal Compact View', compactSessionViewMinimalDescription: 'Remove avatars and show a very compact session row layout', + text: 'Text', + textDescription: 'Adjust text size across the app', + textSize: 'Text Size', + textSizeDescription: 'Make text larger or smaller', + textSizeOptions: { + xxsmall: 'Extra extra small', + xsmall: 'Extra small', + small: 'Small', + default: 'Default', + large: 'Large', + xlarge: 'Extra large', + xxlarge: 'Extra extra large', + }, }, settingsFeatures: { @@ -502,10 +518,10 @@ export const en = { missingPermissionId: 'Missing permission request id', codexResumeNotInstalledTitle: 'Codex resume is not installed on this machine', codexResumeNotInstalledMessage: - 'To resume a Codex conversation, install the Codex resume server on the target machine (Machine Details → Codex resume).', + 'To resume a Codex conversation, install the Codex resume server on the target machine (Machine Details → Installables).', codexAcpNotInstalledTitle: 'Codex ACP is not installed on this machine', codexAcpNotInstalledMessage: - 'To use the Codex ACP experiment, install codex-acp on the target machine (Machine Details → Codex ACP) or disable the experiment.', + 'To use the Codex ACP experiment, install codex-acp on the target machine (Machine Details → Installables) or disable the experiment.', }, deps: { @@ -904,6 +920,8 @@ export const en = { kiloSessionIdCopied: 'Kilo Session ID copied to clipboard', piSessionId: 'Pi Session ID', piSessionIdCopied: 'Pi Session ID copied to clipboard', + copilotSessionId: 'Copilot Session ID', + copilotSessionIdCopied: 'Copilot Session ID copied to clipboard', metadataCopied: 'Metadata copied to clipboard', failedToCopyMetadata: 'Failed to copy metadata', failedToKillSession: 'Failed to kill session', @@ -1047,6 +1065,7 @@ export const en = { kimi: 'Kimi', kilo: 'Kilo', pi: 'Pi', + copilot: 'Copilot', }, auggieIndexingChip: { on: 'Indexing on', @@ -1263,6 +1282,19 @@ export const en = { noChanges: 'No changes to display', }, + settingsNotifications: { + foregroundBehavior: { + title: 'In-app notifications', + footer: 'Controls notifications while you are using the app. Notifications for the session you are viewing are always silenced.', + full: 'Full', + fullDescription: 'Show banner and play sound', + silent: 'Silent', + silentDescription: 'Show banner without sound', + off: 'Off', + offDescription: 'Badge only, no banner', + }, + }, + settingsSession: { messageSending: { title: 'Message sending', @@ -1804,6 +1836,12 @@ export const en = { signUpWithProvider: ({ provider }: { provider: string }) => `Continue with ${provider}`, linkOrRestoreAccount: 'Link or restore account', loginWithMobileApp: 'Login with mobile app', + serverUnavailableTitle: 'Can’t reach server', + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `We can’t connect to ${serverUrl}. Retry or change your server to continue.`, + serverIncompatibleTitle: 'Server not supported', + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `The server at ${serverUrl} returned an unexpected response. Update the server or change your server to continue.`, }, review: { @@ -2282,6 +2320,7 @@ export const en = { kimiSubtitleExperimental: 'Kimi CLI (experimental)', kiloSubtitleExperimental: 'Kilo CLI (experimental)', piSubtitleExperimental: 'Pi CLI (experimental)', + copilotSubtitleExperimental: 'GitHub Copilot CLI (experimental)', }, tmux: { title: 'Tmux', diff --git a/apps/ui/sources/text/translations/es.ts b/apps/ui/sources/text/translations/es.ts index 47e22ad3d..c1881db43 100644 --- a/apps/ui/sources/text/translations/es.ts +++ b/apps/ui/sources/text/translations/es.ts @@ -142,6 +142,10 @@ export const es: TranslationStructure = { terminalUrlPlaceholder: "happier://terminal?...", restoreQrInstructions: '1. Abre Happier en tu dispositivo móvil\n2. Ve a Configuración → Cuenta\n3. Toca "Vincular nuevo dispositivo"\n4. Escanea este código QR', + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => + `${provider} verificado`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `Encontramos una cuenta existente de Happier vinculada a ${provider}. Para terminar de iniciar sesión en este dispositivo, restaura tu clave de cuenta con el código QR o tu clave secreta.`, restoreWithSecretKeyInstead: "Restaurar con clave secreta", restoreWithSecretKeyDescription: "Ingresa tu clave secreta para recuperar el acceso a tu cuenta.", @@ -372,6 +376,19 @@ export const es: TranslationStructure = { compactSessionViewMinimal: "Vista compacta mínima", compactSessionViewMinimalDescription: "Quita los avatares y muestra un diseño de fila de sesión muy compacto", + text: "Texto", + textDescription: "Ajusta el tamaño del texto en la app", + textSize: "Tamaño del texto", + textSizeDescription: "Haz el texto más grande o más pequeño", + textSizeOptions: { + xxsmall: "Muy muy pequeño", + xsmall: "Muy pequeño", + small: "Pequeño", + default: "Predeterminado", + large: "Grande", + xlarge: "Muy grande", + xxlarge: "Muy muy grande", + }, }, settingsFeatures: { @@ -569,7 +586,7 @@ export const es: TranslationStructure = { "Para reanudar una conversación de Codex, instala el servidor de reanudación de Codex en la máquina de destino (Detalles de la máquina → Reanudación de Codex).", codexAcpNotInstalledTitle: "Codex ACP no está instalado en esta máquina", codexAcpNotInstalledMessage: - "Para usar el experimento de Codex ACP, instala codex-acp en la máquina de destino (Detalles de la máquina → Codex ACP) o desactiva el experimento.", + "Para usar el experimento de Codex ACP, instala codex-acp en la máquina de destino (Detalles de la máquina → Installables) o desactiva el experimento.", }, deps: { @@ -1034,6 +1051,8 @@ export const es: TranslationStructure = { kiloSessionIdCopied: "ID de sesión de Kilo copiado al portapapeles", piSessionId: "ID de sesión de Pi", piSessionIdCopied: "ID de sesión de Pi copiado al portapapeles", + copilotSessionId: "Copilot Session ID", + copilotSessionIdCopied: "Copilot Session ID copied to clipboard", metadataCopied: "Metadatos copiados al portapapeles", failedToCopyMetadata: "Falló al copiar metadatos", failedToKillSession: "Falló al terminar sesión", @@ -1196,6 +1215,7 @@ export const es: TranslationStructure = { kimi: "Kimi", kilo: "Kilo", pi: "Pi", + copilot: "Copilot", }, auggieIndexingChip: { on: "Indexing on", @@ -1445,6 +1465,19 @@ export const es: TranslationStructure = { noChanges: "No hay cambios que mostrar", }, + settingsNotifications: { + foregroundBehavior: { + title: "Notificaciones en la app", + footer: "Controla las notificaciones mientras usas la app. Las notificaciones de la sesión que estás viendo siempre se silencian.", + full: "Completas", + fullDescription: "Mostrar banner y reproducir sonido", + silent: "Silenciosas", + silentDescription: "Mostrar banner sin sonido", + off: "Desactivadas", + offDescription: "Solo insignia, sin banner", + }, + }, + settingsSession: { messageSending: { title: "Message sending", @@ -2057,6 +2090,12 @@ export const es: TranslationStructure = { `Continuar con ${provider}`, linkOrRestoreAccount: "Vincular o restaurar cuenta", loginWithMobileApp: "Iniciar sesión con aplicación móvil", + serverUnavailableTitle: "No se puede conectar al servidor", + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `No podemos conectarnos a ${serverUrl}. Reintenta o cambia el servidor para continuar.`, + serverIncompatibleTitle: "Servidor no compatible", + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `El servidor en ${serverUrl} devolvió una respuesta inesperada. Actualiza el servidor o cambia de servidor para continuar.`, }, review: { @@ -2073,7 +2112,7 @@ export const es: TranslationStructure = { `${label} copiado al portapapeles`, }, - machine: { + machine: { offlineUnableToSpawn: "El lanzador está deshabilitado mientras la máquina está desconectada", offlineHelp: @@ -2091,13 +2130,22 @@ export const es: TranslationStructure = { renameTitle: "Renombrar máquina", renameDescription: "Dale a esta máquina un nombre personalizado. Déjalo vacío para usar el hostname predeterminado.", - renamePlaceholder: "Ingresa el nombre de la máquina", - renamedSuccess: "Máquina renombrada correctamente", - renameFailed: "No se pudo renombrar la máquina", - lastKnownPid: "Último PID conocido", - lastKnownHttpPort: "Último puerto HTTP conocido", - startedAt: "Iniciado en", - cliVersion: "Versión del CLI", + renamePlaceholder: "Ingresa el nombre de la máquina", + renamedSuccess: "Máquina renombrada correctamente", + renameFailed: "No se pudo renombrar la máquina", + actions: { + removeMachine: "Remove Machine", + removeMachineSubtitle: + "Revokes this machine and removes it from your account.", + removeMachineConfirmBody: + "This will revoke access from this machine (including access keys and automation assignments). You can reconnect later by signing in again from the CLI.", + removeMachineAlreadyRemoved: + "This machine has already been removed from your account.", + }, + lastKnownPid: "Último PID conocido", + lastKnownHttpPort: "Último puerto HTTP conocido", + startedAt: "Iniciado en", + cliVersion: "Versión del CLI", daemonStateVersion: "Versión del estado del daemon", activeSessions: ({ count }: { count: number }) => `Sesiones activas (${count})`, @@ -2590,6 +2638,7 @@ export const es: TranslationStructure = { kimiSubtitleExperimental: "CLI de Kimi (experimental)", kiloSubtitleExperimental: "CLI de Kilo (experimental)", piSubtitleExperimental: "CLI de Pi (experimental)", + copilotSubtitleExperimental: "GitHub Copilot CLI (experimental)", }, tmux: { title: "Tmux", diff --git a/apps/ui/sources/text/translations/it.ts b/apps/ui/sources/text/translations/it.ts index 77b51507a..afb5a8fd6 100644 --- a/apps/ui/sources/text/translations/it.ts +++ b/apps/ui/sources/text/translations/it.ts @@ -300,6 +300,7 @@ export const it: TranslationStructure = { kimiSubtitleExperimental: "Kimi CLI (sperimentale)", kiloSubtitleExperimental: "Kilo CLI (sperimentale)", piSubtitleExperimental: "Pi CLI (sperimentale)", + copilotSubtitleExperimental: "GitHub Copilot CLI (experimental)", }, tmux: { title: "Tmux", @@ -450,6 +451,10 @@ export const it: TranslationStructure = { terminalUrlPlaceholder: "happier://terminal?...", restoreQrInstructions: '1. Apri Happier sul tuo dispositivo mobile\n2. Vai su Impostazioni → Account\n3. Tocca "Collega nuovo dispositivo"\n4. Scansiona questo codice QR', + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => + `${provider} verificato`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `Abbiamo trovato un account Happier esistente collegato a ${provider}. Per completare l'accesso su questo dispositivo, ripristina la chiave del tuo account con il codice QR o con la tua chiave segreta.`, restoreWithSecretKeyInstead: "Ripristina con chiave segreta", restoreWithSecretKeyDescription: "Inserisci la chiave segreta per ripristinare l’accesso al tuo account.", @@ -679,6 +684,19 @@ export const it: TranslationStructure = { compactSessionViewMinimal: "Vista compatta minima", compactSessionViewMinimalDescription: "Rimuovi gli avatar e mostra un layout di riga sessione molto compatto", + text: "Testo", + textDescription: "Regola la dimensione del testo nell'app", + textSize: "Dimensione testo", + textSizeDescription: "Rendi il testo più grande o più piccolo", + textSizeOptions: { + xxsmall: "Molto molto piccolo", + xsmall: "Molto piccolo", + small: "Piccolo", + default: "Predefinito", + large: "Grande", + xlarge: "Molto grande", + xxlarge: "Molto molto grande", + }, }, settingsFeatures: { @@ -876,7 +894,7 @@ export const it: TranslationStructure = { "Per riprendere una conversazione di Codex, installa il server di ripresa di Codex sulla macchina di destinazione (Dettagli macchina → Ripresa Codex).", codexAcpNotInstalledTitle: "Codex ACP non è installato su questa macchina", codexAcpNotInstalledMessage: - "Per usare l'esperimento Codex ACP, installa codex-acp sulla macchina di destinazione (Dettagli macchina → Codex ACP) o disattiva l'esperimento.", + "Per usare l'esperimento Codex ACP, installa codex-acp sulla macchina di destinazione (Dettagli macchina → Installables) o disattiva l'esperimento.", }, deps: { @@ -1338,6 +1356,8 @@ export const it: TranslationStructure = { kiloSessionIdCopied: "ID sessione Kilo copiato negli appunti", piSessionId: "ID sessione Pi", piSessionIdCopied: "ID sessione Pi copiato negli appunti", + copilotSessionId: "Copilot Session ID", + copilotSessionIdCopied: "Copilot Session ID copied to clipboard", metadataCopied: "Metadati copiati negli appunti", failedToCopyMetadata: "Impossibile copiare i metadati", failedToKillSession: "Impossibile terminare la sessione", @@ -1498,6 +1518,7 @@ export const it: TranslationStructure = { kimi: "Kimi", kilo: "Kilo", pi: "Pi", + copilot: "Copilot", }, auggieIndexingChip: { on: "Indexing on", @@ -1748,6 +1769,19 @@ export const it: TranslationStructure = { noChanges: "Nessuna modifica da mostrare", }, + settingsNotifications: { + foregroundBehavior: { + title: "Notifiche in-app", + footer: "Controlla le notifiche mentre usi l'app. Le notifiche per la sessione che stai visualizzando vengono sempre silenziate.", + full: "Complete", + fullDescription: "Mostra banner e riproduci suono", + silent: "Silenziose", + silentDescription: "Mostra banner senza suono", + off: "Disattivate", + offDescription: "Solo badge, nessun banner", + }, + }, + settingsSession: { messageSending: { title: "Message sending", @@ -2356,6 +2390,12 @@ export const it: TranslationStructure = { `Continua con ${provider}`, linkOrRestoreAccount: "Collega o ripristina account", loginWithMobileApp: "Accedi con l'app mobile", + serverUnavailableTitle: "Impossibile raggiungere il server", + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `Non riusciamo a connetterci a ${serverUrl}. Riprova o cambia server per continuare.`, + serverIncompatibleTitle: "Server non supportato", + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `Il server su ${serverUrl} ha restituito una risposta inattesa. Aggiorna il server o cambia server per continuare.`, }, review: { @@ -2372,7 +2412,7 @@ export const it: TranslationStructure = { `${label} copiato negli appunti`, }, - machine: { + machine: { launchNewSessionInDirectory: "Avvia nuova sessione nella directory", offlineUnableToSpawn: "Avvio disabilitato quando la macchina è offline", offlineHelp: @@ -2389,13 +2429,22 @@ export const it: TranslationStructure = { renameTitle: "Rinomina macchina", renameDescription: "Assegna a questa macchina un nome personalizzato. Lascia vuoto per usare l’hostname predefinito.", - renamePlaceholder: "Inserisci nome macchina", - renamedSuccess: "Macchina rinominata correttamente", - renameFailed: "Impossibile rinominare la macchina", - lastKnownPid: "Ultimo PID noto", - lastKnownHttpPort: "Ultima porta HTTP nota", - startedAt: "Avviato alle", - cliVersion: "Versione CLI", + renamePlaceholder: "Inserisci nome macchina", + renamedSuccess: "Macchina rinominata correttamente", + renameFailed: "Impossibile rinominare la macchina", + actions: { + removeMachine: "Remove Machine", + removeMachineSubtitle: + "Revokes this machine and removes it from your account.", + removeMachineConfirmBody: + "This will revoke access from this machine (including access keys and automation assignments). You can reconnect later by signing in again from the CLI.", + removeMachineAlreadyRemoved: + "This machine has already been removed from your account.", + }, + lastKnownPid: "Ultimo PID noto", + lastKnownHttpPort: "Ultima porta HTTP nota", + startedAt: "Avviato alle", + cliVersion: "Versione CLI", daemonStateVersion: "Versione stato daemon", activeSessions: ({ count }: { count: number }) => `Sessioni attive (${count})`, diff --git a/apps/ui/sources/text/translations/ja.ts b/apps/ui/sources/text/translations/ja.ts index 2608f08cf..415025f0f 100644 --- a/apps/ui/sources/text/translations/ja.ts +++ b/apps/ui/sources/text/translations/ja.ts @@ -287,6 +287,7 @@ export const ja: TranslationStructure = { kimiSubtitleExperimental: "Kimi CLI(実験)", kiloSubtitleExperimental: "Kilo CLI(実験)", piSubtitleExperimental: "Pi CLI(実験)", + copilotSubtitleExperimental: "GitHub Copilot CLI (experimental)", }, tmux: { title: "Tmux", @@ -435,6 +436,10 @@ export const ja: TranslationStructure = { terminalUrlPlaceholder: "happier://terminal?...", restoreQrInstructions: "1. モバイル端末で Happier を開く\n2. 設定 → アカウント に移動\n3. 「新しいデバイスをリンク」をタップ\n4. この QR コードをスキャン", + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => + `${provider} の認証が完了しました`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `${provider} に紐づく既存の Happier アカウントが見つかりました。この端末でサインインを完了するには、QRコードまたはシークレットキーでアカウントキーを復元してください。`, restoreWithSecretKeyInstead: "秘密鍵で復元する", restoreWithSecretKeyDescription: "アカウントへのアクセスを復元するには秘密鍵を入力してください。", @@ -658,6 +663,19 @@ export const ja: TranslationStructure = { compactSessionViewMinimal: "最小コンパクト表示", compactSessionViewMinimalDescription: "アバターを非表示にして、より小さなセッション行レイアウトで表示", + text: "テキスト", + textDescription: "アプリ全体の文字サイズを調整します", + textSize: "文字サイズ", + textSizeDescription: "文字を大きくしたり小さくしたりします", + textSizeOptions: { + xxsmall: "超極小", + xsmall: "極小", + small: "小", + default: "標準", + large: "大", + xlarge: "特大", + xxlarge: "超特大", + }, }, settingsFeatures: { @@ -845,11 +863,11 @@ export const ja: TranslationStructure = { codexResumeNotInstalledTitle: "このマシンには Codex resume がインストールされていません", codexResumeNotInstalledMessage: - "Codex の会話を再開するには、対象のマシンに Codex resume サーバーをインストールしてください(マシン詳細 → Codex resume)。", + "Codex の会話を再開するには、対象のマシンに Codex resume サーバーをインストールしてください(マシン詳細 → Installables)。", codexAcpNotInstalledTitle: "このマシンには Codex ACP がインストールされていません", codexAcpNotInstalledMessage: - "Codex ACP の実験機能を使うには、対象のマシンに codex-acp をインストールしてください(マシン詳細 → Codex ACP)。または実験機能を無効にしてください。", + "Codex ACP の実験機能を使うには、対象のマシンに codex-acp をインストールしてください(マシン詳細 → Installables)。または実験機能を無効にしてください。", }, deps: { @@ -1311,6 +1329,8 @@ export const ja: TranslationStructure = { kiloSessionIdCopied: "Kilo セッション ID をクリップボードにコピーしました", piSessionId: "Pi セッション ID", piSessionIdCopied: "Pi セッション ID をクリップボードにコピーしました", + copilotSessionId: "Copilot Session ID", + copilotSessionIdCopied: "Copilot Session ID copied to clipboard", metadataCopied: "メタデータがクリップボードにコピーされました", failedToCopyMetadata: "メタデータのコピーに失敗しました", failedToKillSession: "セッションの終了に失敗しました", @@ -1471,6 +1491,7 @@ export const ja: TranslationStructure = { kimi: "Kimi", kilo: "Kilo", pi: "Pi", + copilot: "Copilot", }, auggieIndexingChip: { on: "Indexing on", @@ -1716,6 +1737,19 @@ export const ja: TranslationStructure = { noChanges: "表示する変更はありません", }, + settingsNotifications: { + foregroundBehavior: { + title: "アプリ内通知", + footer: "アプリ使用中の通知を制御します。現在表示中のセッションの通知は常にミュートされます。", + full: "フル", + fullDescription: "バナーを表示してサウンドを再生", + silent: "サイレント", + silentDescription: "サウンドなしでバナーを表示", + off: "オフ", + offDescription: "バッジのみ、バナーなし", + }, + }, + settingsSession: { messageSending: { title: "Message sending", @@ -2317,6 +2351,12 @@ export const ja: TranslationStructure = { `${provider}で続行`, linkOrRestoreAccount: "アカウントをリンクまたは復元", loginWithMobileApp: "モバイルアプリでログイン", + serverUnavailableTitle: "サーバーに接続できません", + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `${serverUrl} に接続できません。再試行するか、サーバーを変更して続行してください。`, + serverIncompatibleTitle: "サーバーが未対応です", + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `${serverUrl} のサーバーから想定外の応答が返されました。サーバーを更新するか、サーバーを変更して続行してください。`, }, review: { @@ -2333,7 +2373,7 @@ export const ja: TranslationStructure = { `${label}がクリップボードにコピーされました`, }, - machine: { + machine: { launchNewSessionInDirectory: "ディレクトリで新しいセッションを起動", offlineUnableToSpawn: "マシンがオフラインのためランチャーは無効です", offlineHelp: @@ -2350,13 +2390,22 @@ export const ja: TranslationStructure = { renameTitle: "マシン名を変更", renameDescription: "このマシンにカスタム名を設定します。空欄の場合はデフォルトのホスト名を使用します。", - renamePlaceholder: "マシン名を入力", - renamedSuccess: "マシン名を変更しました", - renameFailed: "マシン名の変更に失敗しました", - lastKnownPid: "最後に確認されたPID", - lastKnownHttpPort: "最後に確認されたHTTPポート", - startedAt: "開始時刻", - cliVersion: "CLIバージョン", + renamePlaceholder: "マシン名を入力", + renamedSuccess: "マシン名を変更しました", + renameFailed: "マシン名の変更に失敗しました", + actions: { + removeMachine: "Remove Machine", + removeMachineSubtitle: + "Revokes this machine and removes it from your account.", + removeMachineConfirmBody: + "This will revoke access from this machine (including access keys and automation assignments). You can reconnect later by signing in again from the CLI.", + removeMachineAlreadyRemoved: + "This machine has already been removed from your account.", + }, + lastKnownPid: "最後に確認されたPID", + lastKnownHttpPort: "最後に確認されたHTTPポート", + startedAt: "開始時刻", + cliVersion: "CLIバージョン", daemonStateVersion: "デーモン状態バージョン", activeSessions: ({ count }: { count: number }) => `アクティブセッション (${count})`, diff --git a/apps/ui/sources/text/translations/pl.ts b/apps/ui/sources/text/translations/pl.ts index 817a9b21c..cbd1563bc 100644 --- a/apps/ui/sources/text/translations/pl.ts +++ b/apps/ui/sources/text/translations/pl.ts @@ -155,6 +155,10 @@ export const pl: TranslationStructure = { terminalUrlPlaceholder: "happier://terminal?...", restoreQrInstructions: "1. Otwórz Happier na urządzeniu mobilnym\n2. Przejdź do Ustawienia → Konto\n3. Dotknij „Połącz nowe urządzenie”\n4. Zeskanuj ten kod QR", + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => + `${provider} zweryfikowano`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `Znaleźliśmy istniejące konto Happier powiązane z ${provider}. Aby dokończyć logowanie na tym urządzeniu, przywróć klucz konta za pomocą kodu QR lub klucza tajnego.`, restoreWithSecretKeyInstead: "Przywróć za pomocą klucza tajnego", restoreWithSecretKeyDescription: "Wpisz swój klucz tajny, aby odzyskać dostęp do konta.", @@ -382,6 +386,19 @@ export const pl: TranslationStructure = { compactSessionViewMinimal: "Minimalny widok kompaktowy", compactSessionViewMinimalDescription: "Usuń awatary i pokaż bardzo kompaktowy układ wiersza sesji", + text: "Tekst", + textDescription: "Dostosuj rozmiar tekstu w aplikacji", + textSize: "Rozmiar tekstu", + textSizeDescription: "Zwiększ lub zmniejsz tekst", + textSizeOptions: { + xxsmall: "Bardzo bardzo mały", + xsmall: "Bardzo mały", + small: "Mały", + default: "Domyślny", + large: "Duży", + xlarge: "Bardzo duży", + xxlarge: "Bardzo bardzo duży", + }, }, settingsFeatures: { @@ -582,7 +599,7 @@ export const pl: TranslationStructure = { codexAcpNotInstalledTitle: "Codex ACP nie jest zainstalowane na tej maszynie", codexAcpNotInstalledMessage: - "Aby użyć eksperymentu Codex ACP, zainstaluj codex-acp na maszynie docelowej (Szczegóły maszyny → Codex ACP) lub wyłącz eksperyment.", + "Aby użyć eksperymentu Codex ACP, zainstaluj codex-acp na maszynie docelowej (Szczegóły maszyny → Installables) lub wyłącz eksperyment.", }, deps: { @@ -1040,6 +1057,8 @@ export const pl: TranslationStructure = { kiloSessionIdCopied: "ID sesji Kilo skopiowane do schowka", piSessionId: "ID sesji Pi", piSessionIdCopied: "ID sesji Pi skopiowane do schowka", + copilotSessionId: "Copilot Session ID", + copilotSessionIdCopied: "Copilot Session ID copied to clipboard", metadataCopied: "Metadane skopiowane do schowka", failedToCopyMetadata: "Nie udało się skopiować metadanych", failedToKillSession: "Nie udało się zakończyć sesji", @@ -1202,6 +1221,7 @@ export const pl: TranslationStructure = { kimi: "Kimi", kilo: "Kilo", pi: "Pi", + copilot: "Copilot", }, auggieIndexingChip: { on: "Indexing on", @@ -1453,6 +1473,19 @@ export const pl: TranslationStructure = { noChanges: "Brak zmian do wyświetlenia", }, + settingsNotifications: { + foregroundBehavior: { + title: "Powiadomienia w aplikacji", + footer: "Kontroluje powiadomienia podczas korzystania z aplikacji. Powiadomienia dla aktualnie przeglądanej sesji są zawsze wyciszane.", + full: "Pełne", + fullDescription: "Pokaż baner i odtwórz dźwięk", + silent: "Ciche", + silentDescription: "Pokaż baner bez dźwięku", + off: "Wyłączone", + offDescription: "Tylko plakietka, bez banera", + }, + }, + settingsSession: { messageSending: { title: "Message sending", @@ -2063,6 +2096,12 @@ export const pl: TranslationStructure = { `Kontynuuj z ${provider}`, linkOrRestoreAccount: "Połącz lub przywróć konto", loginWithMobileApp: "Zaloguj się przez aplikację mobilną", + serverUnavailableTitle: "Nie można połączyć się z serwerem", + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `Nie możemy połączyć się z ${serverUrl}. Spróbuj ponownie lub zmień serwer, aby kontynuować.`, + serverIncompatibleTitle: "Serwer nie jest obsługiwany", + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `Serwer pod adresem ${serverUrl} zwrócił nieoczekiwaną odpowiedź. Zaktualizuj serwer lub zmień serwer, aby kontynuować.`, }, review: { @@ -2079,7 +2118,7 @@ export const pl: TranslationStructure = { `${label} skopiowano do schowka`, }, - machine: { + machine: { offlineUnableToSpawn: "Launcher wyłączony, gdy maszyna jest offline", offlineHelp: "• Upewnij się, że komputer jest online\n• Uruchom `happier daemon status`, aby zdiagnozować\n• Czy używasz najnowszej wersji CLI? Zaktualizuj poleceniem `npm install -g @happier-dev/cli@latest`", @@ -2095,13 +2134,22 @@ export const pl: TranslationStructure = { renameTitle: "Zmień nazwę maszyny", renameDescription: "Nadaj tej maszynie własną nazwę. Pozostaw puste, aby użyć domyślnej nazwy hosta.", - renamePlaceholder: "Wpisz nazwę maszyny", - renamedSuccess: "Nazwa maszyny została zmieniona", - renameFailed: "Nie udało się zmienić nazwy maszyny", - lastKnownPid: "Ostatni znany PID", - lastKnownHttpPort: "Ostatni znany port HTTP", - startedAt: "Uruchomiony o", - cliVersion: "Wersja CLI", + renamePlaceholder: "Wpisz nazwę maszyny", + renamedSuccess: "Nazwa maszyny została zmieniona", + renameFailed: "Nie udało się zmienić nazwy maszyny", + actions: { + removeMachine: "Remove Machine", + removeMachineSubtitle: + "Revokes this machine and removes it from your account.", + removeMachineConfirmBody: + "This will revoke access from this machine (including access keys and automation assignments). You can reconnect later by signing in again from the CLI.", + removeMachineAlreadyRemoved: + "This machine has already been removed from your account.", + }, + lastKnownPid: "Ostatni znany PID", + lastKnownHttpPort: "Ostatni znany port HTTP", + startedAt: "Uruchomiony o", + cliVersion: "Wersja CLI", daemonStateVersion: "Wersja stanu daemon", activeSessions: ({ count }: { count: number }) => `Aktywne sesje (${count})`, @@ -2603,6 +2651,7 @@ export const pl: TranslationStructure = { kimiSubtitleExperimental: "Kimi CLI (eksperymentalne)", kiloSubtitleExperimental: "Kilo CLI (eksperymentalne)", piSubtitleExperimental: "Pi CLI (eksperymentalne)", + copilotSubtitleExperimental: "GitHub Copilot CLI (experimental)", }, tmux: { title: "Tmux", diff --git a/apps/ui/sources/text/translations/pt.ts b/apps/ui/sources/text/translations/pt.ts index 2138f6e04..62ef12f28 100644 --- a/apps/ui/sources/text/translations/pt.ts +++ b/apps/ui/sources/text/translations/pt.ts @@ -142,6 +142,10 @@ export const pt: TranslationStructure = { terminalUrlPlaceholder: "happier://terminal?...", restoreQrInstructions: '1. Abra o Happier no seu dispositivo móvel\n2. Vá em Configurações → Conta\n3. Toque em "Vincular novo dispositivo"\n4. Escaneie este código QR', + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => + `${provider} verificado`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `Encontramos uma conta Happier existente vinculada a ${provider}. Para concluir o login neste dispositivo, restaure a chave da sua conta usando o QR code ou sua chave secreta.`, restoreWithSecretKeyInstead: "Restaurar com chave secreta", restoreWithSecretKeyDescription: "Digite sua chave secreta para recuperar o acesso à sua conta.", @@ -371,6 +375,19 @@ export const pt: TranslationStructure = { compactSessionViewMinimal: "Visualização compacta mínima", compactSessionViewMinimalDescription: "Remover avatares e mostrar um layout de linha de sessão muito compacto", + text: "Texto", + textDescription: "Ajuste o tamanho do texto no app", + textSize: "Tamanho do texto", + textSizeDescription: "Deixe o texto maior ou menor", + textSizeOptions: { + xxsmall: "Muito muito pequeno", + xsmall: "Muito pequeno", + small: "Pequeno", + default: "Padrão", + large: "Grande", + xlarge: "Muito grande", + xxlarge: "Muito muito grande", + }, }, settingsFeatures: { @@ -568,7 +585,7 @@ export const pt: TranslationStructure = { "Para retomar uma conversa do Codex, instale o servidor de retomada do Codex na máquina de destino (Detalhes da máquina → Retomada do Codex).", codexAcpNotInstalledTitle: "O Codex ACP não está instalado nesta máquina", codexAcpNotInstalledMessage: - "Para usar o experimento Codex ACP, instale o codex-acp na máquina de destino (Detalhes da máquina → Codex ACP) ou desative o experimento.", + "Para usar o experimento Codex ACP, instale o codex-acp na máquina de destino (Detalhes da máquina → Installables) ou desative o experimento.", }, deps: { @@ -1037,6 +1054,8 @@ export const pt: TranslationStructure = { "ID da sessão Kilo copiado para a área de transferência", piSessionId: "ID da sessão Pi", piSessionIdCopied: "ID da sessão Pi copiado para a área de transferência", + copilotSessionId: "Copilot Session ID", + copilotSessionIdCopied: "Copilot Session ID copied to clipboard", metadataCopied: "Metadados copiados para a área de transferência", failedToCopyMetadata: "Falha ao copiar metadados", failedToKillSession: "Falha ao encerrar sessão", @@ -1198,6 +1217,7 @@ export const pt: TranslationStructure = { kimi: "Kimi", kilo: "Kilo", pi: "Pi", + copilot: "Copilot", }, auggieIndexingChip: { on: "Indexing on", @@ -1447,6 +1467,19 @@ export const pt: TranslationStructure = { noChanges: "Nenhuma alteração para exibir", }, + settingsNotifications: { + foregroundBehavior: { + title: "Notificações no app", + footer: "Controla as notificações enquanto você usa o app. Notificações da sessão que você está visualizando são sempre silenciadas.", + full: "Completas", + fullDescription: "Mostrar banner e reproduzir som", + silent: "Silenciosas", + silentDescription: "Mostrar banner sem som", + off: "Desativadas", + offDescription: "Apenas badge, sem banner", + }, + }, + settingsSession: { messageSending: { title: "Message sending", @@ -2054,6 +2087,12 @@ export const pt: TranslationStructure = { `Continuar com ${provider}`, linkOrRestoreAccount: "Vincular ou restaurar conta", loginWithMobileApp: "Fazer login com aplicativo móvel", + serverUnavailableTitle: "Não é possível conectar ao servidor", + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `Não conseguimos conectar a ${serverUrl}. Tente novamente ou altere o servidor para continuar.`, + serverIncompatibleTitle: "Servidor não suportado", + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `O servidor em ${serverUrl} retornou uma resposta inesperada. Atualize o servidor ou altere o servidor para continuar.`, }, review: { @@ -2070,7 +2109,7 @@ export const pt: TranslationStructure = { `${label} copiado para a área de transferência`, }, - machine: { + machine: { offlineUnableToSpawn: "Inicializador desativado enquanto a máquina está offline", offlineHelp: @@ -2088,13 +2127,22 @@ export const pt: TranslationStructure = { renameTitle: "Renomear máquina", renameDescription: "Dê a esta máquina um nome personalizado. Deixe em branco para usar o hostname padrão.", - renamePlaceholder: "Digite o nome da máquina", - renamedSuccess: "Máquina renomeada com sucesso", - renameFailed: "Falha ao renomear a máquina", - lastKnownPid: "Último PID conhecido", - lastKnownHttpPort: "Última porta HTTP conhecida", - startedAt: "Iniciado em", - cliVersion: "Versão do CLI", + renamePlaceholder: "Digite o nome da máquina", + renamedSuccess: "Máquina renomeada com sucesso", + renameFailed: "Falha ao renomear a máquina", + actions: { + removeMachine: "Remove Machine", + removeMachineSubtitle: + "Revokes this machine and removes it from your account.", + removeMachineConfirmBody: + "This will revoke access from this machine (including access keys and automation assignments). You can reconnect later by signing in again from the CLI.", + removeMachineAlreadyRemoved: + "This machine has already been removed from your account.", + }, + lastKnownPid: "Último PID conhecido", + lastKnownHttpPort: "Última porta HTTP conhecida", + startedAt: "Iniciado em", + cliVersion: "Versão do CLI", daemonStateVersion: "Versão do estado do daemon", activeSessions: ({ count }: { count: number }) => `Sessões ativas (${count})`, @@ -2527,6 +2575,7 @@ export const pt: TranslationStructure = { kimiSubtitleExperimental: "CLI do Kimi (experimental)", kiloSubtitleExperimental: "CLI do Kilo (experimental)", piSubtitleExperimental: "CLI do Pi (experimental)", + copilotSubtitleExperimental: "GitHub Copilot CLI (experimental)", }, tmux: { title: "Tmux", diff --git a/apps/ui/sources/text/translations/ru.ts b/apps/ui/sources/text/translations/ru.ts index 1669ecaa3..43432bc98 100644 --- a/apps/ui/sources/text/translations/ru.ts +++ b/apps/ui/sources/text/translations/ru.ts @@ -52,8 +52,8 @@ export const ru: TranslationStructure = { }, runs: { - title: "Runs", - empty: "No runs yet.", + title: "Запуски", + empty: "Запусков пока нет.", }, common: { @@ -72,9 +72,9 @@ export const ru: TranslationStructure = { back: "Назад", create: "Создать", rename: "Переименовать", - remove: "Remove", - signOut: "Sign out", - keep: "Keep", + remove: "Удалить", + signOut: "Выйти", + keep: "Оставить", reset: "Сбросить", logout: "Выйти", yes: "Да", @@ -125,6 +125,10 @@ export const ru: TranslationStructure = { terminalUrlPlaceholder: "happier://terminal?...", restoreQrInstructions: "1. Откройте Happier на мобильном устройстве\n2. Перейдите в Настройки → Аккаунт\n3. Нажмите «Подключить новое устройство»\n4. Отсканируйте этот QR‑код", + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => + `${provider} подтверждён`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `Мы нашли существующий аккаунт Happier, связанный с ${provider}. Чтобы завершить вход на этом устройстве, восстановите ключ аккаунта с помощью QR‑кода или секретного ключа.`, restoreWithSecretKeyInstead: "Восстановить по секретному ключу", restoreWithSecretKeyDescription: "Введите секретный ключ, чтобы восстановить доступ к аккаунту.", @@ -152,7 +156,7 @@ export const ru: TranslationStructure = { settings: { title: "Настройки", connectedAccounts: "Подключенные аккаунты", - connectedAccountsDisabled: "Connected services are disabled.", + connectedAccountsDisabled: "Подключённые сервисы отключены.", connectAccount: "Подключить аккаунт", github: "GitHub", machines: "Машины", @@ -227,58 +231,58 @@ export const ru: TranslationStructure = { footer: "Настройте параметры для конкретного провайдера. Эти настройки могут повлиять на поведение сессии.", providerSubtitle: "Параметры для конкретного провайдера", - stateEnabled: "Enabled", - stateDisabled: "Disabled", - channelStable: "Stable", - channelExperimental: "Experimental", - supported: "Supported", - notSupported: "Not supported", - allowed: "Allowed", - notAllowed: "Not allowed", - notAvailable: "Not available", - enabledTitle: "Enabled", - enabledSubtitle: "Use this backend in pickers, profiles, and sessions", - releaseChannelTitle: "Release channel", - capabilitiesTitle: "Capabilities", - resumeSupportTitle: "Resume support", - sessionModeSupportTitle: "Session mode support", - runtimeModeSwitchingTitle: "Runtime mode switching", - localControlTitle: "Local control", - resumeSupportSupported: "Supported", - resumeSupportSupportedExperimental: "Supported (experimental)", + stateEnabled: "Включён", + stateDisabled: "Отключён", + channelStable: "Стабильный", + channelExperimental: "Экспериментальный", + supported: "Поддерживается", + notSupported: "Не поддерживается", + allowed: "Разрешено", + notAllowed: "Не разрешено", + notAvailable: "Недоступно", + enabledTitle: "Включён", + enabledSubtitle: "Использовать этот бэкенд в выборе, профилях и сессиях", + releaseChannelTitle: "Канал выпуска", + capabilitiesTitle: "Возможности", + resumeSupportTitle: "Поддержка возобновления", + sessionModeSupportTitle: "Поддержка режимов сессии", + runtimeModeSwitchingTitle: "Переключение режима в рантайме", + localControlTitle: "Локальное управление", + resumeSupportSupported: "Поддерживается", + resumeSupportSupportedExperimental: "Поддерживается (экспериментально)", resumeSupportRuntimeGatedAcpLoadSession: - "Runtime-gated via ACP loadSession", - resumeSupportNotSupported: "Not supported", - sessionModeNone: "No ACP modes", - sessionModeAcpPolicyPresets: "ACP policy presets", - sessionModeAcpAgentModes: "ACP agent modes", - runtimeSwitchNone: "No runtime switch", - runtimeSwitchMetadataGating: "Metadata-gated", + "Через ACP loadSession в рантайме", + resumeSupportNotSupported: "Не поддерживается", + sessionModeNone: "Нет режимов ACP", + sessionModeAcpPolicyPresets: "Пресеты политик ACP", + sessionModeAcpAgentModes: "Режимы агентов ACP", + runtimeSwitchNone: "Нет переключения в рантайме", + runtimeSwitchMetadataGating: "Через метаданные", runtimeSwitchAcpSetSessionMode: "ACP setSessionMode", - runtimeSwitchProviderNative: "Provider native", - modelsTitle: "Models", - modelSelectionTitle: "Model selection", - freeformModelIdsTitle: "Freeform model IDs", - defaultModelTitle: "Default model", - catalogModelListTitle: "Catalog model list", - catalogModelListEmpty: "No catalog models available", - dynamicModelProbeTitle: "Dynamic model probing", - dynamicModelProbeAuto: "Auto", - dynamicModelProbeStaticOnly: "Static only", - nonAcpApplyScopeTitle: "Non-ACP model apply scope", - nonAcpApplyScopeSpawnOnly: "Apply on session start", - nonAcpApplyScopeNextPrompt: "Apply on next prompt", - acpApplyBehaviorTitle: "ACP model apply behavior", - acpApplyBehaviorSetModel: "Set model live", - acpApplyBehaviorRestartSession: "Restart session", - acpConfigOptionTitle: "ACP model config option id", - cliConnectionTitle: "CLI & Connection", - detectedCliTitle: "Detected CLI", - installSetupTitle: "Install / setup", - installInfoSeeSetupGuide: "See setup guide", - installInfoUseProviderCliInstaller: "Use the provider CLI installer", - setupGuideUrlTitle: "Setup guide URL", - connectedServiceTitle: "Connected service", + runtimeSwitchProviderNative: "Нативный провайдер", + modelsTitle: "Модели", + modelSelectionTitle: "Выбор модели", + freeformModelIdsTitle: "Произвольные ID моделей", + defaultModelTitle: "Модель по умолчанию", + catalogModelListTitle: "Каталог моделей", + catalogModelListEmpty: "Каталог моделей пуст", + dynamicModelProbeTitle: "Динамическое обнаружение моделей", + dynamicModelProbeAuto: "Авто", + dynamicModelProbeStaticOnly: "Только статические", + nonAcpApplyScopeTitle: "Область применения модели (без ACP)", + nonAcpApplyScopeSpawnOnly: "Применить при старте сессии", + nonAcpApplyScopeNextPrompt: "Применить при следующем запросе", + acpApplyBehaviorTitle: "Поведение применения модели (ACP)", + acpApplyBehaviorSetModel: "Установить модель на лету", + acpApplyBehaviorRestartSession: "Перезапустить сессию", + acpConfigOptionTitle: "ID опции конфигурации модели ACP", + cliConnectionTitle: "CLI и подключение", + detectedCliTitle: "Обнаруженный CLI", + installSetupTitle: "Установка / настройка", + installInfoSeeSetupGuide: "Смотрите руководство по настройке", + installInfoUseProviderCliInstaller: "Используйте установщик CLI провайдера", + setupGuideUrlTitle: "URL руководства по настройке", + connectedServiceTitle: "Подключённый сервис", notFoundTitle: "Провайдер не найден", notFoundSubtitle: "У этого провайдера нет экрана настроек.", noOptionsAvailable: "Нет доступных вариантов", @@ -353,6 +357,19 @@ export const ru: TranslationStructure = { compactSessionViewMinimal: "Минимальный компактный вид", compactSessionViewMinimalDescription: "Скрыть аватары и показать очень компактный макет строки сессии", + text: "Текст", + textDescription: "Настройка размера текста в приложении", + textSize: "Размер текста", + textSizeDescription: "Сделать текст больше или меньше", + textSizeOptions: { + xxsmall: "Очень очень маленький", + xsmall: "Очень маленький", + small: "Маленький", + default: "По умолчанию", + large: "Большой", + xlarge: "Очень большой", + xxlarge: "Очень очень большой", + }, }, settingsFeatures: { @@ -366,52 +383,52 @@ export const ru: TranslationStructure = { experimentalOptions: "Экспериментальные опции", experimentalOptionsDescription: "Выберите, какие экспериментальные функции включены.", - expAutomations: "Automations", - expAutomationsSubtitle: "Enable automations UI surfaces and scheduling", - expExecutionRuns: "Execution runs", + expAutomations: "Автоматизации", + expAutomationsSubtitle: "Включить интерфейс автоматизаций и планирование", + expExecutionRuns: "Запуски выполнений", expExecutionRunsSubtitle: - "Enable execution runs (sub-agents / reviews) control plane surfaces", - expAttachmentsUploads: "Attachment uploads", + "Включить панель управления запусками (суб-агенты / ревью)", + expAttachmentsUploads: "Загрузка вложений", expAttachmentsUploadsSubtitle: - "Enable file/image uploads so the agent can read them from disk", - expUsageReporting: "Usage reporting", - expUsageReportingSubtitle: "Enable usage and token reporting screens", + "Включить загрузку файлов/изображений для чтения агентом с диска", + expUsageReporting: "Отчёты об использовании", + expUsageReportingSubtitle: "Включить экраны отчётов об использовании и токенах", expScmOperations: "Операции контроля версий", expScmOperationsSubtitle: "Включить экспериментальные операции записи контроля версий (stage/commit/push/pull)", - expFilesReviewComments: "File review comments", + expFilesReviewComments: "Комментарии к файлам", expFilesReviewCommentsSubtitle: - "Add line-level review comments from file and diff views, then send them as a structured message", - expFilesDiffSyntaxHighlighting: "Diff syntax highlighting", + "Добавлять построчные комментарии из просмотра файлов и diff, отправлять как структурированное сообщение", + expFilesDiffSyntaxHighlighting: "Подсветка синтаксиса в diff", expFilesDiffSyntaxHighlightingSubtitle: - "Enable syntax highlighting in diff and code views (with performance limits)", - expFilesAdvancedSyntaxHighlighting: "Advanced syntax highlighting", + "Включить подсветку синтаксиса в diff и просмотре кода (с ограничениями производительности)", + expFilesAdvancedSyntaxHighlighting: "Расширенная подсветка синтаксиса", expFilesAdvancedSyntaxHighlightingSubtitle: - "Use heavier, higher-fidelity syntax highlighting (web only, may be slower)", - expFilesEditor: "Embedded file editor", + "Использовать более точную подсветку синтаксиса (только веб, может замедлять)", + expFilesEditor: "Встроенный редактор файлов", expFilesEditorSubtitle: - "Enable editing files directly from the file browser (Monaco on web/desktop, CodeMirror on native)", - expShowThinkingMessages: "Show thinking messages", + "Редактирование файлов прямо в файловом менеджере (Monaco на вебе/десктопе, CodeMirror на мобильных)", + expShowThinkingMessages: "Показывать сообщения размышлений", expShowThinkingMessagesSubtitle: - "Show assistant thinking/status messages in chat", - expSessionType: "Session type selector", + "Показывать сообщения размышлений/статуса ассистента в чате", + expSessionType: "Выбор типа сессии", expSessionTypeSubtitle: - "Show the session type selector (simple vs worktree)", + "Показывать выбор типа сессии (простая или worktree)", expZen: "Zen", - expZenSubtitle: "Enable the Zen navigation entry", - expVoiceAuthFlow: "Voice auth flow", + expZenSubtitle: "Включить навигацию Zen", + expVoiceAuthFlow: "Авторизация голоса", expVoiceAuthFlowSubtitle: - "Use authenticated voice token flow (paywall-aware)", + "Использовать авторизованный голосовой поток (с учётом подписки)", voice: "Голос", voiceSubtitle: "Включить голосовые функции", - expVoiceAgent: "Voice agent", - expVoiceAgentSubtitle: "Enable daemon-backed voice agent surfaces (requires execution runs)", - expConnectedServices: "Connected services", - expConnectedServicesSubtitle: "Enable connected services settings and session bindings", - expConnectedServicesQuotas: "Connected services quotas", - expConnectedServicesQuotasSubtitle: "Show quota badges and usage meters for connected services", - expMemorySearch: "Memory search", - expMemorySearchSubtitle: "Enable local memory search screens and settings", + expVoiceAgent: "Голосовой агент", + expVoiceAgentSubtitle: "Включить голосовые поверхности на базе демона (требуются запуски выполнений)", + expConnectedServices: "Подключённые сервисы", + expConnectedServicesSubtitle: "Включить настройки подключённых сервисов и привязку к сессиям", + expConnectedServicesQuotas: "Квоты подключённых сервисов", + expConnectedServicesQuotasSubtitle: "Показывать бейджи квот и счётчики использования подключённых сервисов", + expMemorySearch: "Поиск по памяти", + expMemorySearchSubtitle: "Включить экраны и настройки локального поиска по памяти", expFriends: "Друзья", expFriendsSubtitle: "Включить функции друзей (вкладка «Входящие» и обмен сессиями)", webFeatures: "Веб-функции", @@ -421,14 +438,14 @@ export const ru: TranslationStructure = { enterToSendEnabled: "Нажмите Enter для отправки (Shift+Enter для новой строки)", enterToSendDisabled: "Enter вставляет новую строку", - historyScope: "Message history", - historyScopePerSession: "Cycle history per terminal", - historyScopeGlobal: "Cycle history across all terminals", - historyScopeModalTitle: "Message history", + historyScope: "История сообщений", + historyScopePerSession: "Перебор истории по терминалу", + historyScopeGlobal: "Перебор истории по всем терминалам", + historyScopeModalTitle: "История сообщений", historyScopeModalMessage: - "Choose whether ArrowUp/ArrowDown cycles through messages sent in this terminal only, or across all terminals.", - historyScopePerSessionOption: "Per terminal", - historyScopeGlobalOption: "Global", + "Выберите, перебирает ли ArrowUp/ArrowDown сообщения только этого терминала или всех терминалов.", + historyScopePerSessionOption: "По терминалу", + historyScopeGlobalOption: "Глобально", commandPalette: "Палитра команд", commandPaletteEnabled: "Нажмите ⌘K для открытия", commandPaletteDisabled: "Быстрый доступ к командам отключён", @@ -453,9 +470,9 @@ export const ru: TranslationStructure = { "Группировать неактивные сессии по проектам", groupInactiveSessionsByProjectSubtitle: "Организовать неактивные чаты по проектам", - environmentBadge: "Environment badge", + environmentBadge: "Бейдж окружения", environmentBadgeSubtitle: - "Show a small badge next to the Happier title indicating the current app environment", + "Показывать маленький бейдж рядом с названием Happier с текущим окружением приложения", enhancedSessionWizard: "Улучшенный мастер сессий", enhancedSessionWizardEnabled: "Лаунчер с профилем активен", enhancedSessionWizardDisabled: "Используется стандартный лаунчер", @@ -547,7 +564,7 @@ export const ru: TranslationStructure = { "Чтобы возобновить разговор Codex, установите сервер возобновления Codex на целевой машине (Детали машины → Возобновление Codex).", codexAcpNotInstalledTitle: "Codex ACP не установлен на этой машине", codexAcpNotInstalledMessage: - "Чтобы использовать эксперимент Codex ACP, установите codex-acp на целевой машине (Детали машины → Codex ACP) или отключите эксперимент.", + "Чтобы использовать эксперимент Codex ACP, установите codex-acp на целевой машине (Детали машины → Installables) или отключите эксперимент.", }, deps: { @@ -783,46 +800,46 @@ export const ru: TranslationStructure = { customServerUrlLabel: "URL пользовательского сервера", advancedFeatureFooter: "Это расширенная функция. Изменяйте сервер только если знаете, что делаете. Вам нужно будет выйти и войти снова после изменения серверов.", - useThisServer: "Use this server", + useThisServer: "Использовать этот сервер", autoConfigHint: - "If you’re self-hosting: configure the server first, then sign in (or create an account), then connect your terminal.", - renameServer: "Rename server", - renameServerPrompt: "Enter a new name for this server.", - renameServerGroup: "Rename server group", - renameServerGroupPrompt: "Enter a new name for this server group.", - serverNamePlaceholder: "Server name", - cannotRenameCloud: "You can’t rename the cloud server.", - removeServer: "Remove server", + "Если вы хостите сами: сначала настройте сервер, затем войдите (или создайте аккаунт), затем подключите терминал.", + renameServer: "Переименовать сервер", + renameServerPrompt: "Введите новое имя для этого сервера.", + renameServerGroup: "Переименовать группу серверов", + renameServerGroupPrompt: "Введите новое имя для этой группы серверов.", + serverNamePlaceholder: "Имя сервера", + cannotRenameCloud: "Облачный сервер нельзя переименовать.", + removeServer: "Удалить сервер", removeServerConfirm: ({ name }: { name: string }) => - `Remove "${name}" from saved servers?`, - removeServerGroup: "Remove server group", + `Удалить "${name}" из сохранённых серверов?`, + removeServerGroup: "Удалить группу серверов", removeServerGroupConfirm: ({ name }: { name: string }) => - `Remove "${name}" from saved server groups?`, - cannotRemoveCloud: "You can’t remove the cloud server.", - signOutThisServer: "Also sign out from this server?", + `Удалить "${name}" из сохранённых групп серверов?`, + cannotRemoveCloud: "Облачный сервер нельзя удалить.", + signOutThisServer: "Также выйти с этого сервера?", signOutThisServerPrompt: - "Stored credentials were found for this server on this device.", - savedServersTitle: "Saved servers", - signedIn: "Signed in", - signedOut: "Signed out", - authStatusUnknown: "Auth status unknown", - switchToServer: "Switch to this server", - active: "Active", - default: "Default", - addServerTitle: "Add server", - switchForThisTab: "Switch for this tab", - makeDefaultOnDevice: "Make default on this device", - serverNameLabel: "Server name", - addAndUse: "Add and use", - addTargetsTitle: "Add", - addServerSubtitle: "Add a new server and switch to it", - addServerGroupTitle: "Add server group", - addServerGroupSubtitle: "Create a reusable group of servers", - serverGroupNameLabel: "Group name", - serverGroupNamePlaceholder: "My server group", - serverGroupServersLabel: "Servers", - saveServerGroup: "Save group", - serverGroupMustHaveServer: "A server group must include at least one server.", + "На этом устройстве найдены сохранённые учётные данные для этого сервера.", + savedServersTitle: "Сохранённые серверы", + signedIn: "Авторизован", + signedOut: "Не авторизован", + authStatusUnknown: "Статус авторизации неизвестен", + switchToServer: "Переключиться на этот сервер", + active: "Активный", + default: "По умолчанию", + addServerTitle: "Добавить сервер", + switchForThisTab: "Переключить для этой вкладки", + makeDefaultOnDevice: "Сделать по умолчанию на этом устройстве", + serverNameLabel: "Имя сервера", + addAndUse: "Добавить и использовать", + addTargetsTitle: "Добавить", + addServerSubtitle: "Добавить новый сервер и переключиться на него", + addServerGroupTitle: "Добавить группу серверов", + addServerGroupSubtitle: "Создать группу серверов для повторного использования", + serverGroupNameLabel: "Имя группы", + serverGroupNamePlaceholder: "Моя группа серверов", + serverGroupServersLabel: "Серверы", + saveServerGroup: "Сохранить группу", + serverGroupMustHaveServer: "Группа серверов должна включать хотя бы один сервер.", }, sessionTags: { @@ -870,6 +887,8 @@ export const ru: TranslationStructure = { kiloSessionIdCopied: "ID сессии Kilo скопирован в буфер обмена", piSessionId: "ID сессии Pi", piSessionIdCopied: "ID сессии Pi скопирован в буфер обмена", + copilotSessionId: "ID сессии Copilot", + copilotSessionIdCopied: "ID сессии Copilot скопирован в буфер обмена", metadataCopied: "Метаданные скопированы в буфер обмена", failedToCopyMetadata: "Не удалось скопировать метаданные", failedToKillSession: "Не удалось завершить сессию", @@ -901,7 +920,7 @@ export const ru: TranslationStructure = { happyHome: "Домашний каталог Happier", attachFromTerminal: "Подключиться из терминала", tmuxTarget: "Цель tmux", - tmuxFallback: "Fallback tmux", + tmuxFallback: "Запасной tmux", copyMetadata: "Копировать метаданные", agentState: "Состояние агента", rawJsonDevMode: "Сырой JSON (режим разработчика)", @@ -1036,7 +1055,7 @@ export const ru: TranslationStructure = { `Эта сессия завершена и не может быть возобновлена, потому что ${provider} не поддерживает восстановление контекста здесь. Начните новую сессию, чтобы продолжить.`, machineOfflineNoticeTitle: "Машина не в сети", machineOfflineNoticeBody: ({ machine }: { machine: string }) => - `“${machine}” не в сети, поэтому Happier пока не может возобновить эту сессию. Подключите машину, чтобы продолжить.`, + `"${machine}" не в сети, поэтому Happier пока не может возобновить эту сессию. Подключите машину, чтобы продолжить.`, machineOfflineCannotResume: "Машина не в сети. Подключите её, чтобы возобновить эту сессию.", sharing: { @@ -1200,6 +1219,7 @@ export const ru: TranslationStructure = { kimi: "Kimi", kilo: "Kilo", pi: "Pi", + copilot: "Copilot", }, auggieIndexingChip: { on: "Индексация включена", @@ -1332,8 +1352,8 @@ export const ru: TranslationStructure = { webSearch: "Веб-поиск", reasoning: "Рассуждение", applyChanges: "Обновить файл", - viewDiff: "Diff", - turnDiff: "Turn diff", + viewDiff: "Изменения в файле", + turnDiff: "Изменения за ход", question: "Вопрос", changeTitle: "Изменить заголовок", }, @@ -1404,22 +1424,22 @@ export const ru: TranslationStructure = { summary: ({ staged, unstaged }: { staged: number; unstaged: number }) => `${staged} подготовлено • ${unstaged} не подготовлено`, repositoryChangedFiles: ({ count }: { count: number }) => - `Repository changed files (${count})`, + `Изменённые файлы репозитория (${count})`, sessionAttributedChanges: ({ count }: { count: number }) => - `Session-attributed changes (${count})`, + `Изменения, привязанные к сессии (${count})`, otherRepositoryChanges: ({ count }: { count: number }) => - `Other repository changes (${count})`, + `Прочие изменения репозитория (${count})`, attributionReliabilityHigh: - "Best effort attribution. Repository view remains the source of truth.", + "Наилучшая атрибуция. Представление репозитория остаётся источником истины.", attributionReliabilityLimited: - "Reliability limited: multiple sessions are active for this repository. Showing direct attribution only.", + "Надёжность ограничена: несколько сессий активны для этого репозитория. Показана только прямая атрибуция.", attributionLegendFull: - "direct = from this session operations, inferred = snapshot-based attribution", - attributionLegendDirectOnly: "direct = from this session operations", + "прямая = из операций этой сессии, выведенная = атрибуция на основе снимков", + attributionLegendDirectOnly: "прямая = из операций этой сессии", inferredSuppressed: ({ count }: { count: number }) => - `${count} inferred file${count === 1 ? "" : "s"} kept in repository-only changes.`, + `${count} ${plural({ count, one: "выведенный файл оставлен", few: "выведенных файла оставлены", many: "выведенных файлов оставлены" })} в изменениях только репозитория.`, noSessionAttributedChanges: - "No session-attributed changes currently detected.", + "Изменения, привязанные к сессии, не обнаружены.", notRepo: "Не является репозиторием системы контроля версий", notUnderSourceControl: "Эта папка не находится под управлением системы контроля версий", searching: "Поиск файлов...", @@ -1451,120 +1471,133 @@ export const ru: TranslationStructure = { noChanges: "Нет изменений для отображения", }, + settingsNotifications: { + foregroundBehavior: { + title: "Уведомления в приложении", + footer: "Управляет уведомлениями, пока вы используете приложение. Уведомления для просматриваемой сессии всегда скрываются.", + full: "Полные", + fullDescription: "Показывать баннер и воспроизводить звук", + silent: "Тихие", + silentDescription: "Показывать баннер без звука", + off: "Выкл.", + offDescription: "Только значок, без баннера", + }, + }, + settingsSession: { messageSending: { - title: "Message sending", + title: "Отправка сообщений", footer: - "Controls what happens when you send a message while the agent is running.", - queueInAgentTitle: "Queue in agent (current)", + "Определяет, что происходит при отправке сообщения, пока агент работает.", + queueInAgentTitle: "В очередь агента (текущий)", queueInAgentSubtitle: - "Write to transcript immediately; agent processes when ready.", - interruptTitle: "Interrupt & send", - interruptSubtitle: "Abort current turn, then send immediately.", - pendingTitle: "Pending until ready", + "Записать в стенограмму сразу; агент обработает, когда будет готов.", + interruptTitle: "Прервать и отправить", + interruptSubtitle: "Прервать текущий ход, затем отправить немедленно.", + pendingTitle: "Ожидание готовности", pendingSubtitle: - "Keep messages in a pending queue; agent pulls when ready.", - busySteerPolicyTitle: "When the agent is busy (steer-capable)", + "Сообщения ожидают в очереди; агент забирает, когда готов.", + busySteerPolicyTitle: "Когда агент занят (с поддержкой управления)", busySteerPolicyFooter: - "If the agent supports in-flight steering, choose whether messages steer immediately or go to Pending first.", + "Если агент поддерживает управление на лету, выберите, отправлять ли сообщения сразу или сначала в «Ожидание».", busySteerPolicy: { - steerImmediatelyTitle: "Steer immediately", + steerImmediatelyTitle: "Управлять сразу", steerImmediatelySubtitle: - "Send right away and steer the current turn (no interrupt).", - queueForReviewTitle: "Queue in Pending", + "Отправить сразу и направить текущий ход (без прерывания).", + queueForReviewTitle: "В очередь «Ожидание»", queueForReviewSubtitle: - "Put messages into Pending first; send later using “Steer now”.", + "Сначала поместить в «Ожидание»; отправить позже через «Направить сейчас».", }, }, thinking: { - title: "Thinking", + title: "Размышления", footer: - "Controls how agent thinking messages appear in the session transcript.", - displayModeTitle: "Thinking display", + "Определяет, как сообщения размышлений агента отображаются в стенограмме сессии.", + displayModeTitle: "Отображение размышлений", displayMode: { - inlineTitle: "Inline (default)", - inlineSubtitle: "Show thinking messages directly in the transcript.", - toolTitle: "Tool card", - toolSubtitle: "Show thinking messages as a Reasoning tool card.", - hiddenTitle: "Hidden", - hiddenSubtitle: "Hide thinking messages from the transcript.", + inlineTitle: "Встроенное (по умолчанию)", + inlineSubtitle: "Показывать размышления прямо в стенограмме.", + toolTitle: "Карточка инструмента", + toolSubtitle: "Показывать размышления как карточку инструмента «Рассуждение».", + hiddenTitle: "Скрытое", + hiddenSubtitle: "Скрывать размышления из стенограммы.", }, }, toolRendering: { - title: "Tool rendering", + title: "Отображение инструментов", footer: - "Controls how much tool detail is shown in the session timeline. This is a UI preference; it does not change agent behavior.", - defaultToolDetailLevelTitle: "Default tool detail level", - localControlDefaultTitle: "Local-control default", - showDebugByDefaultTitle: "Show debug by default", + "Определяет, сколько деталей инструментов показывается на шкале времени сессии. Это настройка интерфейса, не влияет на поведение агента.", + defaultToolDetailLevelTitle: "Уровень детализации по умолчанию", + localControlDefaultTitle: "По умолчанию для локального управления", + showDebugByDefaultTitle: "Показывать отладку по умолчанию", showDebugByDefaultSubtitle: - "Auto-expand raw tool payloads in the full tool view.", + "Авторазворот исходных данных инструмента в полном просмотре.", }, toolDetailOverrides: { - title: "Tool detail overrides", + title: "Переопределения детализации инструментов", footer: - "Override the detail level for specific tools. Overrides apply to the canonical tool name (V2), after legacy normalization.", + "Переопределить уровень детализации для конкретных инструментов. Применяется к каноническому имени инструмента (V2) после нормализации.", }, permissions: { - title: "Permissions", - entrySubtitle: "Open permissions settings", + title: "Разрешения", + entrySubtitle: "Открыть настройки разрешений", footer: - "Configure default permissions and how changes apply to running sessions.", + "Настройте разрешения по умолчанию и порядок применения изменений к запущенным сессиям.", applyChangesFooter: - "Choose when permission changes take effect for running sessions.", + "Выберите, когда изменения разрешений вступают в силу для запущенных сессий.", backendFooter: - "Set the default permission mode used when starting sessions with this backend.", - defaultPermissionModeTitle: "Default permission mode", + "Задайте режим разрешений по умолчанию при запуске сессий с этим бэкендом.", + defaultPermissionModeTitle: "Режим разрешений по умолчанию", applyTiming: { - immediateTitle: "Apply immediately", - nextPromptTitle: "Apply on next message", + immediateTitle: "Применить немедленно", + nextPromptTitle: "Применить при следующем сообщении", }, }, defaultPermissions: { - title: "Default permissions", + title: "Разрешения по умолчанию", footer: - "Applies when starting a new session. Profiles can optionally override this.", - applyPermissionChangesTitle: "Apply permission changes", + "Применяются при запуске новой сессии. Профили могут переопределять.", + applyPermissionChangesTitle: "Применение изменений разрешений", applyPermissionChangesImmediateSubtitle: - "Apply immediately for running sessions (updates session metadata).", - applyPermissionChangesNextPromptSubtitle: "Apply on next message only.", + "Применить немедленно для запущенных сессий (обновление метаданных сессии).", + applyPermissionChangesNextPromptSubtitle: "Применить только при следующем сообщении.", }, replayResume: { - title: "Replay resume", + title: "Воспроизведение для возобновления", footer: - "When vendor resume is unavailable, optionally replay recent transcript messages into a new session as context.", - enabledTitle: "Enable replay resume", + "Когда возобновление провайдера недоступно, можно воспроизвести недавние сообщения стенограммы в новой сессии как контекст.", + enabledTitle: "Включить воспроизведение для возобновления", enabledSubtitleOn: - "Offer replay-based resume when vendor resume is unavailable.", - enabledSubtitleOff: "Do not offer replay-based resume.", - strategyTitle: "Replay strategy", + "Предлагать возобновление через воспроизведение, когда возобновление провайдера недоступно.", + enabledSubtitleOff: "Не предлагать возобновление через воспроизведение.", + strategyTitle: "Стратегия воспроизведения", strategy: { - recentTitle: "Recent messages", - recentSubtitle: "Use only the most recent transcript messages.", - summaryRecentTitle: "Summary + recent (experimental)", + recentTitle: "Недавние сообщения", + recentSubtitle: "Использовать только последние сообщения стенограммы.", + summaryRecentTitle: "Сводка + недавние (экспериментально)", summaryRecentSubtitle: - "Include a short summary and recent messages (best-effort).", + "Включить краткую сводку и недавние сообщения (по возможности).", }, - recentMessagesTitle: "Recent messages to include", + recentMessagesTitle: "Количество недавних сообщений", recentMessagesPlaceholder: "16", }, toolDetailLevel: { - titleOnlyTitle: "Title only", - titleOnlySubtitle: "Show only the tool name (no body) in the timeline.", - summaryTitle: "Summary", - summarySubtitle: "Show a compact, safe summary in the timeline.", - fullTitle: "Full", - fullSubtitle: "Show full details inline in the timeline.", - defaultTitle: "Default", - defaultSubtitle: "Use the global default.", + titleOnlyTitle: "Только заголовок", + titleOnlySubtitle: "Показывать только название инструмента (без тела) на шкале времени.", + summaryTitle: "Сводка", + summarySubtitle: "Показывать компактную, безопасную сводку на шкале времени.", + fullTitle: "Полное", + fullSubtitle: "Показывать полные детали прямо на шкале времени.", + defaultTitle: "По умолчанию", + defaultSubtitle: "Использовать глобальную настройку по умолчанию.", }, terminalConnect: { - title: "Terminal connect", - legacySecretExportTitle: "Legacy secret export (compatibility)", + title: "Подключение терминала", + legacySecretExportTitle: "Экспорт устаревшего секрета (совместимость)", legacySecretExportEnabledSubtitle: - "Enabled: exports your legacy account secret to the terminal so older terminals can connect. Not recommended.", + "Включено: экспортирует устаревший секрет аккаунта в терминал для подключения старых терминалов. Не рекомендуется.", legacySecretExportDisabledSubtitle: - "Disabled (recommended): provision terminals with the content key only (Terminal Connect V2).", + "Отключено (рекомендуется): использовать только ключ контента для терминалов (Terminal Connect V2).", }, sessionList: { title: "Список сессий", @@ -1655,14 +1688,14 @@ export const ru: TranslationStructure = { createAccount: "Создать аккаунт ElevenLabs", createAccountSubtitle: "Зарегистрируйтесь (или войдите), прежде чем создавать API-ключ", - openApiKeys: "Open ElevenLabs API keys", + openApiKeys: "Открыть API-ключи ElevenLabs", openApiKeysSubtitle: "ElevenLabs → Developers → API Keys → Create API key", - apiKeyHelp: "How to create an API key", + apiKeyHelp: "Как создать API-ключ", apiKeyHelpSubtitle: - "Step-by-step help for creating and copying your ElevenLabs API key", - apiKeyHelpDialogTitle: "Create an ElevenLabs API key", + "Пошаговая инструкция по созданию и копированию API-ключа ElevenLabs", + apiKeyHelpDialogTitle: "Создание API-ключа ElevenLabs", apiKeyHelpDialogBody: - "Open ElevenLabs → Developers → API Keys → Create API key → Copy the key.", + "Откройте ElevenLabs → Developers → API Keys → Create API key → скопируйте ключ.", autoprovCreate: "Создать агента Happier", autoprovCreateSubtitle: "Создать и настроить агента Happier в вашем аккаунте ElevenLabs с помощью API-ключа", @@ -1686,21 +1719,21 @@ export const ru: TranslationStructure = { apiKeyDescription: "Введите ваш API-ключ ElevenLabs. Он хранится на устройстве в зашифрованном виде.", apiKeyPlaceholder: "xi-api-key", - voiceSearchPlaceholder: "Search voices", - speakerBoostTitle: "Speaker boost", - speakerBoostSubtitle: "Improve clarity and presence (optional).", - speakerBoostAuto: "Auto", - speakerBoostAutoSubtitle: "Use ElevenLabs default.", - speakerBoostOn: "On", - speakerBoostOnSubtitle: "Force enable speaker boost.", - speakerBoostOff: "Off", - speakerBoostOffSubtitle: "Force disable speaker boost.", - voiceGroupTitle: "Voice", + voiceSearchPlaceholder: "Поиск голосов", + speakerBoostTitle: "Усиление голоса", + speakerBoostSubtitle: "Улучшить чёткость и присутствие (необязательно).", + speakerBoostAuto: "Авто", + speakerBoostAutoSubtitle: "Использовать настройку ElevenLabs по умолчанию.", + speakerBoostOn: "Вкл", + speakerBoostOnSubtitle: "Принудительно включить усиление голоса.", + speakerBoostOff: "Выкл", + speakerBoostOffSubtitle: "Принудительно отключить усиление голоса.", + voiceGroupTitle: "Голос", voiceGroupFooter: - "Choose how your ElevenLabs agent speaks. Changes apply when you update the agent.", - provisioningGroupTitle: "Agent provisioning", + "Выберите, как говорит ваш агент ElevenLabs. Изменения применяются при обновлении агента.", + provisioningGroupTitle: "Подготовка агента", provisioningGroupFooter: - "If you change voice/tuning, tap Update Agent to apply it in ElevenLabs.", + "Если вы меняете голос/настройки, нажмите «Обновить агента» для применения в ElevenLabs.", apiKeySaveFailed: "Не удалось сохранить API-ключ. Пожалуйста, попробуйте ещё раз.", disconnect: "Отключить", @@ -1765,35 +1798,35 @@ export const ru: TranslationStructure = { mediatorCommitModelSourceChat: "Модель чата", mediatorCommitModelSourceSession: "Модель сессии", mediatorCommitModelSourceCustom: "Своя модель", - chatBaseUrl: "Chat Base URL", - chatBaseUrlTitle: "Chat Base URL", + chatBaseUrl: "Базовый URL чата", + chatBaseUrlTitle: "Базовый URL чата", chatBaseUrlDescription: "Базовый URL для OpenAI-совместимого chat completion эндпоинта (обычно заканчивается на /v1).", chatApiKey: "Chat API-ключ", chatApiKeyTitle: "Chat API-ключ", chatApiKeyDescription: "Необязательный API-ключ для chat сервера (хранится в зашифрованном виде). Оставьте пустым, чтобы очистить.", - chatModel: "Chat модель", + chatModel: "Модель чата", chatModelSubtitle: "Быстрая модель для живого голосового диалога", - chatModelTitle: "Chat модель", + chatModelTitle: "Модель чата", chatModelDescription: "Имя модели, отправляемое на chat сервер (OpenAI-совместимое поле).", modelCustomTitle: "Свой…", modelCustomSubtitle: "Введите ID модели", - commitModel: "Commit модель", + commitModel: "Модель коммита", commitModelSubtitle: "Модель для генерации финального сообщения-инструкции", - commitModelTitle: "Commit модель", + commitModelTitle: "Модель коммита", commitModelDescription: "Имя модели, отправляемое при генерации финального commit сообщения.", - chatTemperature: "Chat temperature", + chatTemperature: "Температура чата", chatTemperatureSubtitle: "Управляет случайностью (0–2)", - chatTemperatureTitle: "Chat temperature", + chatTemperatureTitle: "Температура чата", chatTemperatureDescription: "Введите число от 0 до 2.", chatTemperatureInvalid: "Введите число от 0 до 2.", - chatMaxTokens: "Chat max tokens", + chatMaxTokens: "Макс. токенов чата", chatMaxTokensSubtitle: "Ограничить длину ответа (пусто = по умолчанию)", - chatMaxTokensTitle: "Chat max tokens", + chatMaxTokensTitle: "Макс. токенов чата", chatMaxTokensDescription: "Введите положительное целое число или оставьте пустым.", chatMaxTokensPlaceholder: "Пусто = по умолчанию", @@ -1812,9 +1845,9 @@ export const ru: TranslationStructure = { sttModelTitle: "STT модель", sttModelDescription: "Имя модели, отправляемое на STT сервер (OpenAI-совместимое поле).", - deviceStt: "Device STT (experimental)", + deviceStt: "STT на устройстве (экспериментально)", deviceSttSubtitle: - "Use on-device speech recognition instead of an OpenAI-compatible endpoint", + "Использовать распознавание речи на устройстве вместо OpenAI-совместимого эндпоинта", ttsBaseUrl: "TTS Base URL", ttsBaseUrlTitle: "TTS Base URL", ttsBaseUrlDescription: @@ -1835,19 +1868,19 @@ export const ru: TranslationStructure = { "Имя/ID голоса, отправляемое на TTS сервер (OpenAI-совместимое поле).", ttsFormat: "TTS формат", ttsFormatSubtitle: "Формат аудио, возвращаемый TTS", - testTts: "Test TTS", + testTts: "Тест TTS", testTtsSubtitle: - "Play a short sample using your configured local TTS (device TTS or endpoint)", - testTtsSample: "Hello from Happier. This is a test of your local TTS.", - testTtsMissingBaseUrl: "Set a TTS Base URL first.", + "Воспроизвести короткий пример с текущими настройками локального TTS (на устройстве или через эндпоинт)", + testTtsSample: "Привет от Happier. Это тест вашего локального TTS.", + testTtsMissingBaseUrl: "Сначала укажите TTS Base URL.", testTtsFailed: - "TTS test failed. Check your base URL, API key, model, and voice.", - deviceTts: "Device TTS (experimental)", + "Тест TTS не удался. Проверьте base URL, API-ключ, модель и голос.", + deviceTts: "TTS на устройстве (экспериментально)", deviceTtsSubtitle: - "Use on-device speech synthesis instead of an OpenAI-compatible endpoint", - ttsProvider: "TTS Provider", + "Использовать синтез речи на устройстве вместо OpenAI-совместимого эндпоинта", + ttsProvider: "Провайдер TTS", ttsProviderSubtitle: - "Choose device TTS, an OpenAI-compatible endpoint, or Kokoro (web/desktop)", + "Выберите TTS на устройстве, OpenAI-совместимый эндпоинт или Kokoro (веб/десктоп)", autoSpeak: "Авто-озвучивание ответов", autoSpeakSubtitle: @@ -1870,8 +1903,8 @@ export const ru: TranslationStructure = { recentMessagesCountInvalid: "Введите число от 0 до 50.", shareToolNames: "Передавать имена инструментов", shareToolNamesSubtitle: "Добавлять имена/описания инструментов в голосовой контекст", - shareDeviceInventory: "Share device inventory", - shareDeviceInventorySubtitle: "Allow voice to list recent workspaces, machines, and servers", + shareDeviceInventory: "Передавать список устройств", + shareDeviceInventorySubtitle: "Разрешить голосу просматривать недавние рабочие области, машины и серверы", shareToolArgs: "Передавать аргументы инструментов", shareToolArgsSubtitle: "Добавлять аргументы инструментов (может содержать пути или секреты)", sharePermissionRequests: "Передавать запросы разрешений", @@ -1987,7 +2020,7 @@ export const ru: TranslationStructure = { linkProcessedLocally: "Ссылка обработана локально в браузере", linkProcessedOnDevice: "Ссылка обработана локально на устройстве", switchServerToConnectTerminal: ({ serverUrl }: { serverUrl: string }) => - `This connection is for ${serverUrl}. Switch servers and continue?`, + `Это подключение для ${serverUrl}. Переключить сервер и продолжить?`, }, modals: { @@ -2000,7 +2033,7 @@ export const ru: TranslationStructure = { terminalConnectionAlreadyUsedDescription: "Эта ссылка для подключения уже была использована другим устройством. Чтобы подключить несколько устройств к одному терминалу, выйдите из системы и войдите в одну и ту же учетную запись на всех устройствах.", authRequestExpired: "Подключение истекло", authRequestExpiredDescription: "Срок действия ссылки для подключения истек. Создайте новую ссылку с вашего терминала.", - pleaseSignInFirst: "Please sign in (or create an account) first.", + pleaseSignInFirst: "Сначала войдите в аккаунт (или создайте новый).", invalidAuthUrl: "Неверный URL авторизации", microphoneAccessRequiredTitle: "Требуется доступ к микрофону", microphoneAccessRequiredRequestPermission: @@ -2048,6 +2081,12 @@ export const ru: TranslationStructure = { `Продолжить через ${provider}`, linkOrRestoreAccount: "Связать или восстановить аккаунт", loginWithMobileApp: "Войти через мобильное приложение", + serverUnavailableTitle: "Не удаётся подключиться к серверу", + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `Мы не можем подключиться к ${serverUrl}. Повторите попытку или смените сервер, чтобы продолжить.`, + serverIncompatibleTitle: "Сервер не поддерживается", + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `Сервер по адресу ${serverUrl} вернул неожиданный ответ. Обновите сервер или смените сервер, чтобы продолжить.`, }, review: { @@ -2064,7 +2103,7 @@ export const ru: TranslationStructure = { `${label} скопировано в буфер обмена`, }, - machine: { + machine: { offlineUnableToSpawn: "Запуск отключён: машина офлайн", offlineHelp: "• Убедитесь, что компьютер онлайн\n• Выполните `happier daemon status` для диагностики\n• Используете последнюю версию CLI? Обновите командой `npm install -g @happier-dev/cli@latest`", @@ -2080,13 +2119,22 @@ export const ru: TranslationStructure = { renameTitle: "Переименовать машину", renameDescription: "Дайте этой машине имя. Оставьте пустым, чтобы использовать hostname по умолчанию.", - renamePlaceholder: "Введите имя машины", - renamedSuccess: "Машина успешно переименована", - renameFailed: "Не удалось переименовать машину", - lastKnownPid: "Последний известный PID", - lastKnownHttpPort: "Последний известный HTTP порт", - startedAt: "Запущен в", - cliVersion: "Версия CLI", + renamePlaceholder: "Введите имя машины", + renamedSuccess: "Машина успешно переименована", + renameFailed: "Не удалось переименовать машину", + actions: { + removeMachine: "Удалить машину", + removeMachineSubtitle: + "Отзывает доступ этой машины и удаляет её из вашего аккаунта.", + removeMachineConfirmBody: + "Это отзовёт доступ с этой машины (включая ключи доступа и назначения автоматизаций). Вы сможете подключиться позже, снова войдя через CLI.", + removeMachineAlreadyRemoved: + "Эта машина уже удалена из вашего аккаунта.", + }, + lastKnownPid: "Последний известный PID", + lastKnownHttpPort: "Последний известный HTTP порт", + startedAt: "Запущен в", + cliVersion: "Версия CLI", daemonStateVersion: "Версия состояния daemon", activeSessions: ({ count }: { count: number }) => `Активные сессии (${count})`, @@ -2605,6 +2653,7 @@ export const ru: TranslationStructure = { kimiSubtitleExperimental: "Kimi CLI (экспериментально)", kiloSubtitleExperimental: "Kilo CLI (экспериментально)", piSubtitleExperimental: "Pi CLI (экспериментально)", + copilotSubtitleExperimental: "GitHub Copilot CLI (экспериментально)", }, tmux: { title: "Tmux", diff --git a/apps/ui/sources/text/translations/zh-Hans.ts b/apps/ui/sources/text/translations/zh-Hans.ts index f20615297..3e4a02a08 100644 --- a/apps/ui/sources/text/translations/zh-Hans.ts +++ b/apps/ui/sources/text/translations/zh-Hans.ts @@ -141,6 +141,10 @@ export const zhHans: TranslationStructure = { terminalUrlPlaceholder: "happier://terminal?...", restoreQrInstructions: "1. 在你的手机上打开 Happier\n2. 前往 设置 → 账户\n3. 点击“链接新设备”\n4. 扫描此二维码", + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => + `${provider} 验证完成`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `我们找到了与 ${provider} 关联的现有 Happier 账户。要在此设备上完成登录,请使用二维码或你的密钥恢复账户密钥。`, restoreWithSecretKeyInstead: "改用密钥恢复", restoreWithSecretKeyDescription: "输入你的密钥以恢复账户访问权限。", lostAccessLink: "无法访问?", @@ -354,6 +358,19 @@ export const zhHans: TranslationStructure = { compactSessionViewDescription: "以更紧凑的布局显示活跃会话", compactSessionViewMinimal: "极简紧凑视图", compactSessionViewMinimalDescription: "隐藏头像并显示更紧凑的会话行布局", + text: "文本", + textDescription: "调整应用内文字大小", + textSize: "文字大小", + textSizeDescription: "让文字更大或更小", + textSizeOptions: { + xxsmall: "超特小", + xsmall: "特小", + small: "小", + default: "默认", + large: "大", + xlarge: "特大", + xxlarge: "超特大", + }, }, settingsFeatures: { @@ -535,10 +552,10 @@ export const zhHans: TranslationStructure = { missingPermissionId: "缺少权限请求 ID", codexResumeNotInstalledTitle: "此机器未安装 Codex resume", codexResumeNotInstalledMessage: - "要恢复 Codex 对话,请在目标机器上安装 Codex resume 服务器(机器详情 → Codex resume)。", + "要恢复 Codex 对话,请在目标机器上安装 Codex resume 服务器(机器详情 → Installables)。", codexAcpNotInstalledTitle: "此机器未安装 Codex ACP", codexAcpNotInstalledMessage: - "要使用 Codex ACP 实验功能,请在目标机器上安装 codex-acp(机器详情 → Codex ACP),或关闭实验开关。", + "要使用 Codex ACP 实验功能,请在目标机器上安装 codex-acp(机器详情 → Installables),或关闭实验开关。", }, deps: { @@ -968,6 +985,8 @@ export const zhHans: TranslationStructure = { kiloSessionIdCopied: "Kilo 会话 ID 已复制到剪贴板", piSessionId: "Pi 会话 ID", piSessionIdCopied: "Pi 会话 ID 已复制到剪贴板", + copilotSessionId: "Copilot Session ID", + copilotSessionIdCopied: "Copilot Session ID copied to clipboard", metadataCopied: "元数据已复制到剪贴板", failedToCopyMetadata: "复制元数据失败", failedToKillSession: "终止会话失败", @@ -1125,6 +1144,7 @@ export const zhHans: TranslationStructure = { kimi: "Kimi", kilo: "Kilo", pi: "Pi", + copilot: "Copilot", }, auggieIndexingChip: { on: "已开启索引", @@ -1367,6 +1387,19 @@ export const zhHans: TranslationStructure = { noChanges: "没有要显示的更改", }, + settingsNotifications: { + foregroundBehavior: { + title: "应用内通知", + footer: "控制您使用应用时的通知方式。当前正在查看的会话的通知始终会被静音。", + full: "完整", + fullDescription: "显示横幅并播放声音", + silent: "静音", + silentDescription: "显示横幅但不播放声音", + off: "关闭", + offDescription: "仅显示角标,不显示横幅", + }, + }, + settingsSession: { messageSending: { title: "Message sending", @@ -1940,6 +1973,12 @@ export const zhHans: TranslationStructure = { `使用 ${provider} 继续`, linkOrRestoreAccount: "链接或恢复账户", loginWithMobileApp: "使用移动应用登录", + serverUnavailableTitle: "无法连接到服务器", + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `无法连接到 ${serverUrl}。请重试或更改服务器以继续。`, + serverIncompatibleTitle: "服务器不受支持", + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `${serverUrl} 返回了意外的响应。请更新服务器或更改服务器以继续。`, }, review: { @@ -1956,7 +1995,7 @@ export const zhHans: TranslationStructure = { `${label} 已复制到剪贴板`, }, - machine: { + machine: { launchNewSessionInDirectory: "在目录中启动新会话", offlineUnableToSpawn: "设备离线时无法启动", offlineHelp: @@ -1971,13 +2010,22 @@ export const zhHans: TranslationStructure = { stopDaemonFailed: "停止守护进程失败。它可能未在运行。", renameTitle: "重命名设备", renameDescription: "为此设备设置自定义名称。留空则使用默认主机名。", - renamePlaceholder: "输入设备名称", - renamedSuccess: "设备重命名成功", - renameFailed: "设备重命名失败", - lastKnownPid: "最后已知 PID", - lastKnownHttpPort: "最后已知 HTTP 端口", - startedAt: "启动时间", - cliVersion: "CLI 版本", + renamePlaceholder: "输入设备名称", + renamedSuccess: "设备重命名成功", + renameFailed: "设备重命名失败", + actions: { + removeMachine: "Remove Machine", + removeMachineSubtitle: + "Revokes this machine and removes it from your account.", + removeMachineConfirmBody: + "This will revoke access from this machine (including access keys and automation assignments). You can reconnect later by signing in again from the CLI.", + removeMachineAlreadyRemoved: + "This machine has already been removed from your account.", + }, + lastKnownPid: "最后已知 PID", + lastKnownHttpPort: "最后已知 HTTP 端口", + startedAt: "启动时间", + cliVersion: "CLI 版本", daemonStateVersion: "守护进程状态版本", activeSessions: ({ count }: { count: number }) => `活跃会话 (${count})`, machineGroup: "设备", @@ -2382,6 +2430,7 @@ export const zhHans: TranslationStructure = { kimiSubtitleExperimental: "Kimi 命令行(实验)", kiloSubtitleExperimental: "Kilo 命令行(实验)", piSubtitleExperimental: "Pi 命令行(实验)", + copilotSubtitleExperimental: "GitHub Copilot CLI (experimental)", }, tmux: { title: "tmux", diff --git a/apps/ui/sources/text/translations/zh-Hant.ts b/apps/ui/sources/text/translations/zh-Hant.ts index 6223e9834..d1854193a 100644 --- a/apps/ui/sources/text/translations/zh-Hant.ts +++ b/apps/ui/sources/text/translations/zh-Hant.ts @@ -131,6 +131,9 @@ const zhHantOverrides: DeepPartial<TranslationStructure> = { enterSecretKey: '請輸入金鑰', invalidSecretKey: '無效的金鑰,請檢查後重試。', enterUrlManually: '手動輸入 URL', + externalAuthVerifiedTitle: ({ provider }: { provider: string }) => `${provider} 驗證完成`, + externalAuthVerifiedBody: ({ provider }: { provider: string }) => + `我們找到了與 ${provider} 關聯的既有 Happier 帳戶。要在此裝置上完成登入,請使用 QR code 或你的密鑰來還原帳戶金鑰。`, linkNewDeviceTitle: '連結新裝置', linkNewDeviceSubtitle: '掃描新裝置上顯示的 QR Code 以將其連結至此帳戶', linkNewDeviceQrInstructions: '在新裝置上開啟 Happier 並顯示 QR Code', @@ -242,6 +245,19 @@ const zhHantOverrides: DeepPartial<TranslationStructure> = { compactSessionViewDescription: '以更緊湊的版面配置顯示活躍工作階段', compactSessionViewMinimal: '極簡緊湊檢視', compactSessionViewMinimalDescription: '隱藏頭像並顯示更緊湊的工作階段列版面', + text: '文字', + textDescription: '調整應用程式內文字大小', + textSize: '文字大小', + textSizeDescription: '讓文字更大或更小', + textSizeOptions: { + xxsmall: '超特小', + xsmall: '特小', + small: '小', + default: '預設', + large: '大', + xlarge: '特大', + xxlarge: '超特大', + }, }, settingsFeatures: { @@ -485,6 +501,8 @@ const zhHantOverrides: DeepPartial<TranslationStructure> = { claudeCodeSessionIdCopied: 'Claude Code 工作階段 ID 已複製到剪貼簿', aiProvider: 'AI 提供者', failedToCopyClaudeCodeSessionId: '複製 Claude Code 工作階段 ID 失敗', + copilotSessionId: 'Copilot 工作階段 ID', + copilotSessionIdCopied: 'Copilot 工作階段 ID 已複製到剪貼簿', metadataCopied: '中繼資料已複製到剪貼簿', failedToCopyMetadata: '複製中繼資料失敗', failedToKillSession: '終止工作階段失敗', @@ -562,6 +580,7 @@ const zhHantOverrides: DeepPartial<TranslationStructure> = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', + copilot: 'Copilot', }, model: { title: '模型', @@ -731,6 +750,19 @@ const zhHantOverrides: DeepPartial<TranslationStructure> = { noChanges: '沒有要顯示的更改', }, + settingsNotifications: { + foregroundBehavior: { + title: '應用程式內通知', + footer: '控制您使用應用程式時的通知方式。目前正在檢視的工作階段通知一律會靜音。', + full: '完整', + fullDescription: '顯示橫幅並播放音效', + silent: '靜音', + silentDescription: '顯示橫幅但不播放音效', + off: '關閉', + offDescription: '僅顯示徽章,不顯示橫幅', + }, + }, + settingsSession: { messageSending: { title: 'Message sending', @@ -1154,6 +1186,12 @@ const zhHantOverrides: DeepPartial<TranslationStructure> = { signUpWithProvider: ({ provider }: { provider: string }) => `使用 ${provider} 繼續`, linkOrRestoreAccount: '連結或恢復帳戶', loginWithMobileApp: '使用行動應用程式登入', + serverUnavailableTitle: '無法連線到伺服器', + serverUnavailableBody: ({ serverUrl }: { serverUrl: string }) => + `無法連線到 ${serverUrl}。請重試或更改伺服器以繼續。`, + serverIncompatibleTitle: '伺服器不受支援', + serverIncompatibleBody: ({ serverUrl }: { serverUrl: string }) => + `${serverUrl} 回傳了意外的回應。請更新伺服器或更改伺服器以繼續。`, }, review: { @@ -1384,6 +1422,9 @@ const zhHantOverrides: DeepPartial<TranslationStructure> = { confirm: '刪除', cancel: '取消', }, + aiBackend: { + copilotSubtitleExperimental: 'GitHub Copilot CLI(實驗)', + }, } } as const; diff --git a/apps/ui/sources/theme.ts b/apps/ui/sources/theme.ts index 080399f01..15e221b82 100644 --- a/apps/ui/sources/theme.ts +++ b/apps/ui/sources/theme.ts @@ -42,6 +42,15 @@ export const lightTheme = { textDestructive: Platform.select({ ios: '#FF3B30', default: '#F44336' }), textSecondary: Platform.select({ ios: '#8E8E93', default: '#49454F' }), textLink: '#2BACCC', + accent: { + blue: '#007AFF', + green: '#34C759', + orange: '#FF9500', + yellow: '#FFCC00', + red: '#FF3B30', + indigo: Platform.select({ ios: '#5856D6', default: '#5C6BC0' }), + purple: Platform.select({ ios: '#AF52DE', default: '#9C27B0' }), + }, deleteAction: '#FF6B6B', // Delete/remove button color warningCritical: '#FF3B30', warning: '#8E8E93', @@ -251,6 +260,15 @@ export const darkTheme = { textDestructive: Platform.select({ ios: '#FF453A', default: '#F48FB1' }), textSecondary: Platform.select({ ios: '#8E8E93', default: '#CAC4D0' }), textLink: '#2BACCC', + accent: { + blue: '#0A84FF', + green: '#32D74B', + orange: '#FF9F0A', + yellow: '#FFD60A', + red: '#FF453A', + indigo: Platform.select({ ios: '#5E5CE6', default: '#9FA8DA' }), + purple: Platform.select({ ios: '#BF5AF2', default: '#CE93D8' }), + }, deleteAction: '#FF6B6B', // Delete/remove button color (same in both themes) warningCritical: '#FF453A', warning: '#8E8E93', diff --git a/apps/ui/sources/utils/errors/daemonUnavailableAlert.test.ts b/apps/ui/sources/utils/errors/daemonUnavailableAlert.test.ts new file mode 100644 index 000000000..ece1bf0bb --- /dev/null +++ b/apps/ui/sources/utils/errors/daemonUnavailableAlert.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; + +const modalAlertSpy = vi.hoisted(() => vi.fn((..._args: unknown[]) => {})); + +vi.mock('@/modal', () => ({ + Modal: { + alert: modalAlertSpy, + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string, params?: Record<string, unknown>) => { + if (key === 'status.lastSeen') return `status.lastSeen:${String(params?.time ?? '')}`; + if (key === 'time.minutesAgo') return `time.minutesAgo:${String(params?.count ?? '')}`; + if (key === 'time.hoursAgo') return `time.hoursAgo:${String(params?.count ?? '')}`; + if (key === 'sessionHistory.daysAgo') return `sessionHistory.daysAgo:${String(params?.count ?? '')}`; + return key; + }, +})); + +describe('daemonUnavailableAlert', () => { + it('shows a translated alert with machine status and Retry/Cancel buttons', async () => { + modalAlertSpy.mockClear(); + vi.resetModules(); + const { showDaemonUnavailableAlert } = await import('./daemonUnavailableAlert'); + + const onRetry = vi.fn(); + showDaemonUnavailableAlert({ + titleKey: 'errors.daemonUnavailableTitle', + bodyKey: 'errors.daemonUnavailableBody', + machine: { + active: false, + activeAt: Date.now() - 5 * 60_000, + metadata: { host: 'devbox' }, + }, + onRetry, + }); + + expect(modalAlertSpy).toHaveBeenCalled(); + const args = modalAlertSpy.mock.calls[0] ?? []; + expect(args[0]).toBe('errors.daemonUnavailableTitle'); + expect(String(args[1] ?? '')).toContain('errors.daemonUnavailableBody'); + expect(String(args[1] ?? '')).toContain('status.lastSeen:time.minutesAgo:5'); + + const buttons = args[2] as any[]; + expect(Array.isArray(buttons)).toBe(true); + expect(buttons.some((b) => b?.text === 'common.retry' && typeof b?.onPress === 'function')).toBe(true); + expect(buttons.some((b) => b?.text === 'common.cancel')).toBe(true); + }); + + it('guards Retry when shouldContinue returns false', async () => { + modalAlertSpy.mockClear(); + vi.resetModules(); + const { showDaemonUnavailableAlert } = await import('./daemonUnavailableAlert'); + + const onRetry = vi.fn(); + showDaemonUnavailableAlert({ + titleKey: 'errors.daemonUnavailableTitle', + bodyKey: 'errors.daemonUnavailableBody', + onRetry, + shouldContinue: () => false, + }); + + const args = modalAlertSpy.mock.calls[0] ?? []; + const buttons = args[2] as any[]; + const retry = buttons.find((b) => b?.text === 'common.retry'); + expect(typeof retry?.onPress).toBe('function'); + + retry.onPress(); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it('shows an alert for RPC method-not-available failure objects (code/message)', async () => { + modalAlertSpy.mockClear(); + vi.resetModules(); + const { tryShowDaemonUnavailableAlertForRpcFailure, DAEMON_UNAVAILABLE_RPC_ERROR_CODE } = await import('./daemonUnavailableAlert'); + + const onRetry = vi.fn(); + const shown = tryShowDaemonUnavailableAlertForRpcFailure({ + rpcErrorCode: DAEMON_UNAVAILABLE_RPC_ERROR_CODE, + message: 'RPC method not available', + onRetry, + }); + + expect(shown).toBe(true); + expect(modalAlertSpy).toHaveBeenCalled(); + }); + + it('does not match by message when a non-daemon rpcErrorCode is present on an Error', async () => { + modalAlertSpy.mockClear(); + vi.resetModules(); + const { tryShowDaemonUnavailableAlertForRpcError } = await import('./daemonUnavailableAlert'); + + const err = Object.assign(new Error('RPC method not available'), { rpcErrorCode: 'SOME_OTHER_CODE' }); + const shown = tryShowDaemonUnavailableAlertForRpcError({ + error: err, + onRetry: vi.fn(), + }); + + expect(shown).toBe(false); + expect(modalAlertSpy).not.toHaveBeenCalled(); + }); + + it('does not match by message when a non-daemon rpcErrorCode is present', async () => { + modalAlertSpy.mockClear(); + vi.resetModules(); + const { tryShowDaemonUnavailableAlertForRpcFailure } = await import('./daemonUnavailableAlert'); + + const shown = tryShowDaemonUnavailableAlertForRpcFailure({ + rpcErrorCode: 'SOME_OTHER_CODE', + message: 'RPC method not available', + onRetry: vi.fn(), + }); + + expect(shown).toBe(false); + expect(modalAlertSpy).not.toHaveBeenCalled(); + }); + + it('returns false and does not alert for non-RPC method-not-available errors', async () => { + modalAlertSpy.mockClear(); + vi.resetModules(); + const { tryShowDaemonUnavailableAlertForRpcError, tryShowDaemonUnavailableAlertForRpcFailure } = await import('./daemonUnavailableAlert'); + + const shown = tryShowDaemonUnavailableAlertForRpcError({ + error: new Error('boom'), + onRetry: vi.fn(), + }); + + expect(shown).toBe(false); + expect(modalAlertSpy).not.toHaveBeenCalled(); + + const shown2 = tryShowDaemonUnavailableAlertForRpcFailure({ + rpcErrorCode: 'OTHER', + message: 'boom', + onRetry: vi.fn(), + }); + expect(shown2).toBe(false); + expect(modalAlertSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ui/sources/utils/errors/daemonUnavailableAlert.ts b/apps/ui/sources/utils/errors/daemonUnavailableAlert.ts new file mode 100644 index 000000000..07b90b12e --- /dev/null +++ b/apps/ui/sources/utils/errors/daemonUnavailableAlert.ts @@ -0,0 +1,134 @@ +import { RPC_ERROR_CODES } from '@happier-dev/protocol/rpc'; +import { isRpcMethodNotAvailableError, readRpcErrorCode } from '@happier-dev/protocol/rpcErrors'; + +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { formatLastSeen } from '@/utils/sessions/sessionUtils'; + +export type MachineStatusLineInput = + | Readonly<{ + active?: boolean | null; + activeAt?: number | null; + metadata?: Readonly<{ displayName?: string | null; host?: string | null }> | null; + }> + | null + | undefined; + +function resolveMachineName(machine: MachineStatusLineInput): string | null { + const displayName = typeof machine?.metadata?.displayName === 'string' ? machine.metadata.displayName.trim() : ''; + if (displayName) return displayName; + const host = typeof machine?.metadata?.host === 'string' ? machine.metadata.host.trim() : ''; + return host || null; +} + +export function buildMachineStatusLine(machine: MachineStatusLineInput): string { + const machineStatus = (() => { + const activeAt = typeof machine?.activeAt === 'number' ? machine.activeAt : null; + if (activeAt !== null) { + if (machine?.active === true) return t('status.online'); + return t('status.lastSeen', { time: formatLastSeen(activeAt, false) }); + } + return t('status.unknown'); + })(); + + const machineName = resolveMachineName(machine); + return machineName ? `${machineName} — ${machineStatus}` : machineStatus; +} + +export function showDaemonUnavailableAlert(params: Readonly<{ + titleKey: string; + bodyKey: string; + machine?: MachineStatusLineInput; + onRetry?: (() => void) | null; + shouldContinue?: (() => boolean) | null; +}>): void { + const statusLine = buildMachineStatusLine(params.machine); + const message = `${t(params.bodyKey)}\n\n${statusLine}`; + const guardedRetry = params.onRetry + ? () => { + if (params.shouldContinue && !params.shouldContinue()) { + return; + } + params.onRetry?.(); + } + : null; + const buttons = [ + ...(guardedRetry + ? [ + { + text: t('common.retry'), + onPress: guardedRetry, + }, + ] + : []), + { + text: t('common.cancel'), + style: 'cancel' as const, + }, + ]; + + Modal.alert(t(params.titleKey), message, buttons); +} + +export function tryShowDaemonUnavailableAlertForRpcError(params: Readonly<{ + error: unknown; + machine?: MachineStatusLineInput; + onRetry?: (() => void) | null; + shouldContinue?: (() => boolean) | null; + titleKey?: string; + bodyKey?: string; +}>): boolean { + const rpcErrorCode = readRpcErrorCode(params.error); + if (typeof rpcErrorCode === 'string' && rpcErrorCode.trim() && rpcErrorCode.trim() !== DAEMON_UNAVAILABLE_RPC_ERROR_CODE) { + return false; + } + if (!isRpcMethodNotAvailableError(params.error as any)) { + return false; + } + + showDaemonUnavailableAlert({ + titleKey: params.titleKey ?? 'errors.daemonUnavailableTitle', + bodyKey: params.bodyKey ?? 'errors.daemonUnavailableBody', + machine: params.machine, + onRetry: params.onRetry ?? null, + shouldContinue: params.shouldContinue ?? null, + }); + + return true; +} + +export function tryShowDaemonUnavailableAlertForRpcFailure(params: Readonly<{ + rpcErrorCode?: string | null; + message?: string | null; + machine?: MachineStatusLineInput; + onRetry?: (() => void) | null; + shouldContinue?: (() => boolean) | null; + titleKey?: string; + bodyKey?: string; +}>): boolean { + const normalizedCode = typeof params.rpcErrorCode === 'string' ? params.rpcErrorCode.trim() : ''; + if (normalizedCode && normalizedCode !== DAEMON_UNAVAILABLE_RPC_ERROR_CODE) { + return false; + } + + const carrier = { + rpcErrorCode: normalizedCode || undefined, + message: typeof params.message === 'string' ? params.message : undefined, + }; + + if (!isRpcMethodNotAvailableError(carrier as any)) { + return false; + } + + showDaemonUnavailableAlert({ + titleKey: params.titleKey ?? 'errors.daemonUnavailableTitle', + bodyKey: params.bodyKey ?? 'errors.daemonUnavailableBody', + machine: params.machine, + onRetry: params.onRetry ?? null, + shouldContinue: params.shouldContinue ?? null, + }); + + return true; +} + +export const DAEMON_UNAVAILABLE_RPC_ERROR_CODE = RPC_ERROR_CODES.METHOD_NOT_AVAILABLE; diff --git a/apps/ui/sources/utils/errors/formatOperationFailedDebugMessage.test.ts b/apps/ui/sources/utils/errors/formatOperationFailedDebugMessage.test.ts new file mode 100644 index 000000000..bf39a05a0 --- /dev/null +++ b/apps/ui/sources/utils/errors/formatOperationFailedDebugMessage.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; + +import { formatOperationFailedDebugMessage } from './formatOperationFailedDebugMessage'; + +describe('formatOperationFailedDebugMessage', () => { + it('returns base message when error has no detail', () => { + expect(formatOperationFailedDebugMessage('Operation failed', null)).toBe('Operation failed'); + }); + + it('appends error message when provided', () => { + expect(formatOperationFailedDebugMessage('Operation failed', new Error('boom'))).toBe( + 'Operation failed\n\nboom', + ); + }); + + it('handles non-Error inputs', () => { + expect(formatOperationFailedDebugMessage('Operation failed', { message: 'nope' })).toBe( + 'Operation failed\n\nnope', + ); + }); +}); + diff --git a/apps/ui/sources/utils/errors/formatOperationFailedDebugMessage.ts b/apps/ui/sources/utils/errors/formatOperationFailedDebugMessage.ts new file mode 100644 index 000000000..f00dd79df --- /dev/null +++ b/apps/ui/sources/utils/errors/formatOperationFailedDebugMessage.ts @@ -0,0 +1,10 @@ +import { getErrorMessage } from './getErrorMessage'; + +export function formatOperationFailedDebugMessage(baseMessage: string, error: unknown): string { + const base = String(baseMessage ?? '').trim(); + const detail = getErrorMessage(error).trim(); + if (!base) return detail || ''; + if (!detail) return base; + return `${base}\n\n${detail}`; +} + diff --git a/apps/ui/sources/utils/sessions/sync.test.ts b/apps/ui/sources/utils/sessions/sync.test.ts new file mode 100644 index 000000000..a5f773b46 --- /dev/null +++ b/apps/ui/sources/utils/sessions/sync.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { InvalidateSync } from './sync'; + +function createDeferred<T>() { + let resolve!: (value: T | PromiseLike<T>) => void; + const promise = new Promise<T>((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe('InvalidateSync.awaitQueue', () => { + it('resolves after timeout when the queue never completes', async () => { + vi.useFakeTimers(); + try { + const sync = new InvalidateSync(async () => await new Promise<void>(() => {})); + sync.invalidate(); + + let resolved = false; + const promise = sync.awaitQueue({ timeoutMs: 1000 }).then(() => { + resolved = true; + }); + + await vi.advanceTimersByTimeAsync(999); + expect(resolved).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(resolved).toBe(true); + + await promise; + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('InvalidateSync.invalidateCoalesced', () => { + it('does not schedule a second run when invalidated while a run is in flight', async () => { + const started = createDeferred<void>(); + + const command = vi.fn(async () => { + await started.promise; + }); + + const sync = new InvalidateSync(command); + sync.invalidate(); + sync.invalidateCoalesced(); + + expect(command).toHaveBeenCalledTimes(1); + + started.resolve(undefined); + await sync.awaitQueue({ timeoutMs: 2000 }); + + expect(command).toHaveBeenCalledTimes(1); + }); + + it('preserves double-run behavior for regular invalidate()', async () => { + const started = createDeferred<void>(); + + const command = vi.fn(async () => { + await started.promise; + }); + + const sync = new InvalidateSync(command); + sync.invalidate(); + sync.invalidate(); + + expect(command).toHaveBeenCalledTimes(1); + + started.resolve(undefined); + await sync.awaitQueue({ timeoutMs: 2000 }); + + expect(command).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/ui/sources/utils/sessions/sync.ts b/apps/ui/sources/utils/sessions/sync.ts index 6f913058b..c8f10893b 100644 --- a/apps/ui/sources/utils/sessions/sync.ts +++ b/apps/ui/sources/utils/sessions/sync.ts @@ -47,6 +47,18 @@ export class InvalidateSync { } } + invalidateCoalesced() { + if (this._stopped) { + return; + } + if (this._invalidated) { + return; + } + this._invalidated = true; + this._invalidatedDouble = false; + this._doSync(); + } + async invalidateAndAwait() { if (this._stopped) { return; @@ -57,12 +69,35 @@ export class InvalidateSync { }); } - async awaitQueue() { + async awaitQueue(opts?: { timeoutMs?: number }) { if (this._stopped || (!this._invalidated && this._pendings.length === 0)) { return; } - await new Promise<void>(resolve => { - this._pendings.push(resolve); + const timeoutMs = opts?.timeoutMs; + if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + await new Promise<void>(resolve => { + this._pendings.push(resolve); + }); + return; + } + + await new Promise<void>((resolve) => { + let settled = false; + let timer: ReturnType<typeof setTimeout>; + const pending = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(); + }; + + this._pendings.push(pending); + timer = setTimeout(() => { + if (settled) return; + settled = true; + this._pendings = this._pendings.filter((entry) => entry !== pending); + resolve(); + }, timeoutMs); }); } @@ -145,12 +180,35 @@ export class ValueSync<T> { }); } - async awaitQueue() { + async awaitQueue(opts?: { timeoutMs?: number }) { if (this._stopped || (!this._processing && this._pendings.length === 0)) { return; } - await new Promise<void>(resolve => { - this._pendings.push(resolve); + const timeoutMs = opts?.timeoutMs; + if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + await new Promise<void>(resolve => { + this._pendings.push(resolve); + }); + return; + } + + await new Promise<void>((resolve) => { + let settled = false; + let timer: ReturnType<typeof setTimeout>; + const pending = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(); + }; + + this._pendings.push(pending); + timer = setTimeout(() => { + if (settled) return; + settled = true; + this._pendings = this._pendings.filter((entry) => entry !== pending); + resolve(); + }, timeoutMs); }); } diff --git a/apps/ui/sources/utils/system/runtimeFetch.test.ts b/apps/ui/sources/utils/system/runtimeFetch.test.ts new file mode 100644 index 000000000..c95c506f4 --- /dev/null +++ b/apps/ui/sources/utils/system/runtimeFetch.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe('runtimeFetch', () => { + it('defaults credentials to same-origin when omitted', async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response('ok', { status: 200 })); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + + const { runtimeFetch } = await import('./runtimeFetch'); + await runtimeFetch('https://api.example.test/v1/features'); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + expect(init?.credentials).toBe('same-origin'); + }); + + it('preserves explicit credentials', async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response('ok', { status: 200 })); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + + const { runtimeFetch } = await import('./runtimeFetch'); + await runtimeFetch('https://api.example.test/v1/features', { credentials: 'omit' }); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + expect(init?.credentials).toBe('omit'); + }); +}); diff --git a/apps/ui/sources/utils/system/runtimeFetch.ts b/apps/ui/sources/utils/system/runtimeFetch.ts index fc367887d..0811259a0 100644 --- a/apps/ui/sources/utils/system/runtimeFetch.ts +++ b/apps/ui/sources/utils/system/runtimeFetch.ts @@ -5,7 +5,16 @@ const defaultRuntimeFetch: RuntimeFetch = (input, init) => { if (typeof globalFetch !== 'function') { throw new Error('globalThis.fetch is not available'); } - return (globalFetch as unknown as RuntimeFetch)(input, init); + // Some fetch implementations (notably in certain Expo/React Native web builds) default `credentials` + // to `include`, which breaks cross-origin requests against servers that correctly use + // `Access-Control-Allow-Origin: *` (wildcard is not permitted when credentials are included). + // + // The WHATWG Fetch default is `same-origin`. Enforce that default unless a caller explicitly + // overrides it to keep CORS behavior predictable. + const mergedInit = init + ? ({ ...init, credentials: init.credentials ?? 'same-origin' } satisfies RequestInit) + : ({ credentials: 'same-origin' } satisfies RequestInit); + return (globalFetch as unknown as RuntimeFetch)(input, mergedInit); }; let activeRuntimeFetch: RuntimeFetch = defaultRuntimeFetch; diff --git a/apps/ui/sources/voice/agent/VoiceAgentSessionController.ts b/apps/ui/sources/voice/agent/VoiceAgentSessionController.ts index 7490ba745..21b8d892e 100644 --- a/apps/ui/sources/voice/agent/VoiceAgentSessionController.ts +++ b/apps/ui/sources/voice/agent/VoiceAgentSessionController.ts @@ -9,7 +9,11 @@ import type { VoiceAssistantAction } from '@happier-dev/protocol'; import { voiceSettingsDefaults } from '@/sync/domains/settings/voiceSettings'; import { VOICE_AGENT_GLOBAL_SESSION_ID } from '@/voice/agent/voiceAgentGlobalSessionId'; import { useVoiceTargetStore } from '@/voice/runtime/voiceTargetStore'; -import { ensureVoiceCarrierSessionId, findVoiceCarrierSessionId } from '@/voice/agent/voiceCarrierSession'; +import { + ensureVoiceCarrierSessionId, + findVoiceCarrierSessionId, + isVoiceCarrierSystemSessionMetadata, +} from '@/voice/agent/voiceCarrierSession'; import { buildVoiceReplaySeedPromptFromCarrierSession } from '@/voice/persistence/buildVoiceReplaySeedPromptFromCarrierSession'; import { readVoiceAgentRunMetadataFromCarrierSession } from '@/voice/persistence/voiceAgentRunMetadata'; import { writeVoiceAgentRunMetadataToCarrierSession } from '@/voice/persistence/voiceAgentRunMetadata'; @@ -75,8 +79,7 @@ export function createVoiceAgentSessionController(): VoiceAgentSessionController const id = normalizeSessionId(s.id); if (!id) continue; const meta = s.metadata ?? null; - const sys = meta?.systemSessionV1 ?? null; - if (!sys || sys.hidden !== true || String(sys.key ?? '') !== 'voice_carrier') continue; + if (!isVoiceCarrierSystemSessionMetadata(meta)) continue; const updatedAt = typeof s.updatedAt === 'number' && Number.isFinite(s.updatedAt) ? s.updatedAt : 0; if (!bestSystem || updatedAt > bestSystem.updatedAt || (updatedAt === bestSystem.updatedAt && id < bestSystem.id)) { bestSystem = { id, updatedAt }; diff --git a/apps/ui/sources/voice/agent/voiceCarrierSession.ts b/apps/ui/sources/voice/agent/voiceCarrierSession.ts index e6937134d..f32a08eb5 100644 --- a/apps/ui/sources/voice/agent/voiceCarrierSession.ts +++ b/apps/ui/sources/voice/agent/voiceCarrierSession.ts @@ -5,6 +5,7 @@ import { getActiveServerSnapshot } from '@/sync/domains/server/serverRuntime'; import { useVoiceTargetStore } from '@/voice/runtime/voiceTargetStore'; import { DEFAULT_AGENT_ID, type AgentId } from '@happier-dev/agents'; import { isAgentId } from '@/agents/registry/registryCore'; +import { buildSystemSessionMetadataV1, readSystemSessionMetadataFromMetadata } from '@happier-dev/protocol'; export const VOICE_CARRIER_SYSTEM_SESSION_KEY = 'voice_carrier'; const VOICE_CARRIER_RETIRED_SYSTEM_SESSION_KEY = 'voice_carrier_retired'; @@ -14,6 +15,15 @@ function normalizeNonEmptyString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +export function isVoiceCarrierSystemSessionMetadata(metadata: unknown): boolean { + const systemSession = readSystemSessionMetadataFromMetadata({ metadata }); + return systemSession?.hidden === true && String(systemSession.key ?? '') === VOICE_CARRIER_SYSTEM_SESSION_KEY; +} + +function buildVoiceCarrierSystemSessionMetadata() { + return buildSystemSessionMetadataV1({ key: VOICE_CARRIER_SYSTEM_SESSION_KEY, hidden: true }); +} + export function findVoiceCarrierSessionId(state: any): string | null { const sessionsObj = state?.sessions ?? {}; let best: { id: string; updatedAt: number } | null = null; @@ -21,8 +31,7 @@ export function findVoiceCarrierSessionId(state: any): string | null { for (const s of Object.values(sessionsObj) as any[]) { if (!s || typeof s.id !== 'string') continue; const meta = s.metadata ?? null; - const sys = meta?.systemSessionV1 ?? null; - if (!sys || sys.hidden !== true || String(sys.key ?? '') !== VOICE_CARRIER_SYSTEM_SESSION_KEY) continue; + if (!isVoiceCarrierSystemSessionMetadata(meta)) continue; const id = s.id; const updatedAt = typeof s.updatedAt === 'number' && Number.isFinite(s.updatedAt) ? s.updatedAt : 0; @@ -122,7 +131,7 @@ async function touchVoiceCarrierSession(sessionId: string): Promise<void> { const summaryText = typeof metadata?.summary?.text === 'string' ? metadata.summary.text : 'Voice control (system)'; return { ...metadata, - systemSessionV1: { v: 1, key: VOICE_CARRIER_SYSTEM_SESSION_KEY, hidden: true }, + ...buildVoiceCarrierSystemSessionMetadata(), summary: { text: summaryText, updatedAt: Date.now() }, }; }); @@ -140,7 +149,7 @@ function resolveCarrierRetentionLimit(state: any): number { async function retireVoiceCarrierSession(sessionId: string): Promise<void> { await sync.patchSessionMetadataWithRetry(sessionId, (metadata: any) => ({ ...metadata, - systemSessionV1: { v: 1, key: VOICE_CARRIER_RETIRED_SYSTEM_SESSION_KEY, hidden: true }, + ...buildSystemSessionMetadataV1({ key: VOICE_CARRIER_RETIRED_SYSTEM_SESSION_KEY, hidden: true }), voiceAgentRunV1: null, })); } @@ -157,8 +166,7 @@ async function applyVoiceCarrierRetentionPolicy(params: Readonly<{ keepSessionId for (const s of Object.values(sessionsObj) as any[]) { if (!s || typeof s.id !== 'string') continue; const meta = s.metadata ?? null; - const sys = meta?.systemSessionV1 ?? null; - if (!sys || sys.hidden !== true || String(sys.key ?? '') !== VOICE_CARRIER_SYSTEM_SESSION_KEY) continue; + if (!isVoiceCarrierSystemSessionMetadata(meta)) continue; if (s.id === keepSessionId) continue; const updatedAt = typeof s.updatedAt === 'number' && Number.isFinite(s.updatedAt) ? s.updatedAt : 0; carrierSessions.push({ id: s.id, updatedAt }); @@ -187,8 +195,7 @@ export async function ensureVoiceCarrierSessionForVoiceHome(): Promise<string> { for (const s of Object.values(state.sessions ?? {}) as any[]) { if (!s || typeof s.id !== 'string') continue; const meta = s.metadata ?? null; - const sys = meta?.systemSessionV1 ?? null; - if (!sys || sys.hidden !== true || String(sys.key ?? '') !== VOICE_CARRIER_SYSTEM_SESSION_KEY) continue; + if (!isVoiceCarrierSystemSessionMetadata(meta)) continue; if (normalizeNonEmptyString(meta?.machineId) !== target.machineId) continue; if (normalizeNonEmptyString(meta?.path) !== target.directory) continue; const updatedAt = typeof s.updatedAt === 'number' && Number.isFinite(s.updatedAt) ? s.updatedAt : 0; @@ -259,8 +266,7 @@ export async function ensureVoiceCarrierSessionForSessionRoot(params: Readonly<{ for (const s of Object.values(state.sessions ?? {}) as any[]) { if (!s || typeof s.id !== 'string') continue; const meta = s.metadata ?? null; - const sys = meta?.systemSessionV1 ?? null; - if (!sys || sys.hidden !== true || String(sys.key ?? '') !== VOICE_CARRIER_SYSTEM_SESSION_KEY) continue; + if (!isVoiceCarrierSystemSessionMetadata(meta)) continue; if (normalizeNonEmptyString(meta?.machineId) !== machineId) continue; if (normalizeNonEmptyString(meta?.path) !== directory) continue; const updatedAt = typeof s.updatedAt === 'number' && Number.isFinite(s.updatedAt) ? s.updatedAt : 0; diff --git a/apps/ui/sources/voice/output/playAudioBytesWithStopper.spec.ts b/apps/ui/sources/voice/output/playAudioBytesWithStopper.spec.ts index 10162d15d..ac29164fe 100644 --- a/apps/ui/sources/voice/output/playAudioBytesWithStopper.spec.ts +++ b/apps/ui/sources/voice/output/playAudioBytesWithStopper.spec.ts @@ -17,7 +17,7 @@ describe('playAudioBytesWithStopper (web)', () => { let endedHandler: (() => void) | null = null; const stop = vi.fn(() => { - endedHandler?.(); + if (endedHandler) endedHandler(); }); class FakeAudioBufferSourceNode { @@ -76,7 +76,8 @@ describe('playAudioBytesWithStopper (web)', () => { // Allow the async decode/play pipeline to attach handlers. await new Promise((r) => setTimeout(r, 0)); if (!endedHandler) throw new Error('Expected onended to be set'); - endedHandler(); + const onEnded: () => void = endedHandler; + onEnded(); await promise; }); diff --git a/apps/ui/sources/voice/pickers/VoiceSessionSpawnPickerModal.tsx b/apps/ui/sources/voice/pickers/VoiceSessionSpawnPickerModal.tsx index 3dafdfe9f..13841abd3 100644 --- a/apps/ui/sources/voice/pickers/VoiceSessionSpawnPickerModal.tsx +++ b/apps/ui/sources/voice/pickers/VoiceSessionSpawnPickerModal.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, Pressable, ScrollView } from 'react-native'; +import { View, Pressable, ScrollView } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; @@ -19,6 +19,8 @@ import { resolvePreferredMachineId } from '@/components/settings/pickers/resolve import { isMachineOnline } from '@/utils/sessions/machineUtils'; import type { VoiceSessionSpawnPickerResult } from './openVoiceSessionSpawnPicker'; +import { Text } from '@/components/ui/text/Text'; + type Props = CustomModalInjectedProps & Readonly<{ onResolve: (value: VoiceSessionSpawnPickerResult | null) => void; diff --git a/apps/ui/sources/voice/settings/modals/showElevenLabsAgentReuseDialog.tsx b/apps/ui/sources/voice/settings/modals/showElevenLabsAgentReuseDialog.tsx index 75eaeb03b..03c8fb33c 100644 --- a/apps/ui/sources/voice/settings/modals/showElevenLabsAgentReuseDialog.tsx +++ b/apps/ui/sources/voice/settings/modals/showElevenLabsAgentReuseDialog.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { Modal } from '@/modal'; +import { Text } from '@/components/ui/text/Text'; + export type ElevenLabsAgentReuseDecision = 'create_new' | 'update_existing' | 'cancel'; diff --git a/apps/ui/sources/voice/settings/panels/LocalConversationSection.test.tsx b/apps/ui/sources/voice/settings/panels/LocalConversationSection.test.tsx index 6fc238748..a156ccf21 100644 --- a/apps/ui/sources/voice/settings/panels/LocalConversationSection.test.tsx +++ b/apps/ui/sources/voice/settings/panels/LocalConversationSection.test.tsx @@ -126,6 +126,7 @@ vi.mock('@/sync/store/hooks', () => ({ { id: 'machine-1', active: true, createdAt: 1, updatedAt: 1, activeAt: 1, seq: 1, metadata: { host: 'm1', platform: 'darwin', happyCliVersion: '1', happyHomeDir: '/h', homeDir: '/u' }, metadataVersion: 1, daemonState: null, daemonStateVersion: 1 }, { id: 'machine-2', active: false, createdAt: 2, updatedAt: 2, activeAt: 2, seq: 1, metadata: { host: 'm2', platform: 'darwin', happyCliVersion: '1', happyHomeDir: '/h', homeDir: '/u' }, metadataVersion: 1, daemonState: null, daemonStateVersion: 1 }, ], + useLocalSetting: () => 1, })); const featureEnabledState: Record<string, boolean> = { 'voice.agent': true }; diff --git a/apps/ui/sources/voice/settings/panels/VoiceProviderSection.tsx b/apps/ui/sources/voice/settings/panels/VoiceProviderSection.tsx index cab799ac1..0a4ed7fc4 100644 --- a/apps/ui/sources/voice/settings/panels/VoiceProviderSection.tsx +++ b/apps/ui/sources/voice/settings/panels/VoiceProviderSection.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; @@ -12,6 +13,7 @@ export function VoiceProviderSection(props: { setVoice: (next: VoiceSettings) => void; happierVoiceSupported: boolean | null; }) { + const { theme } = useUnistyles(); const select = (next: VoiceSettings) => props.setVoice(next); const billingMode = props.voice.adapters.realtime_elevenlabs.billingMode; @@ -25,7 +27,7 @@ export function VoiceProviderSection(props: { <Item title={t('settingsVoice.mode.off')} subtitle={t('settingsVoice.mode.offSubtitle')} - rightElement={isOff ? <Ionicons name="checkmark-circle" size={24} color="#007AFF" /> : null} + rightElement={isOff ? <Ionicons name="checkmark-circle" size={24} color={theme.colors.accent.blue} /> : null} onPress={() => select({ ...props.voice, providerId: 'off' })} showChevron={false} /> @@ -34,7 +36,7 @@ export function VoiceProviderSection(props: { <Item title={t('settingsVoice.mode.happier')} subtitle={t('settingsVoice.mode.happierSubtitle')} - rightElement={isHappier ? <Ionicons name="checkmark-circle" size={24} color="#007AFF" /> : null} + rightElement={isHappier ? <Ionicons name="checkmark-circle" size={24} color={theme.colors.accent.blue} /> : null} onPress={() => select({ ...props.voice, @@ -52,7 +54,7 @@ export function VoiceProviderSection(props: { <Item title={t('settingsVoice.mode.byo')} subtitle={t('settingsVoice.mode.byoSubtitle')} - rightElement={isByo ? <Ionicons name="checkmark-circle" size={24} color="#007AFF" /> : null} + rightElement={isByo ? <Ionicons name="checkmark-circle" size={24} color={theme.colors.accent.blue} /> : null} onPress={() => select({ ...props.voice, @@ -69,7 +71,7 @@ export function VoiceProviderSection(props: { <Item title={t('settingsVoice.mode.local')} subtitle={t('settingsVoice.mode.localSubtitle')} - rightElement={isLocal ? <Ionicons name="checkmark-circle" size={24} color="#007AFF" /> : null} + rightElement={isLocal ? <Ionicons name="checkmark-circle" size={24} color={theme.colors.accent.blue} /> : null} onPress={() => select({ ...props.voice, providerId: 'local_conversation' })} showChevron={false} /> diff --git a/apps/ui/sources/voice/tools/actionImpl/sessionList.ts b/apps/ui/sources/voice/tools/actionImpl/sessionList.ts index 9a13e2d02..fa3268c0e 100644 --- a/apps/ui/sources/voice/tools/actionImpl/sessionList.ts +++ b/apps/ui/sources/voice/tools/actionImpl/sessionList.ts @@ -1,6 +1,7 @@ import { storage } from '@/sync/domains/state/storage'; import { getSessionName } from '@/utils/sessions/sessionUtils'; import type { Message } from '@/sync/domains/messages/messageTypes'; +import { isHiddenSystemSession } from '@happier-dev/protocol'; import { compareSessionKeyDesc, @@ -29,7 +30,7 @@ export async function listSessionsForVoiceTool(params: Readonly<{ const pushSessionRow = (s: any, serverId: string | null) => { if (!s || typeof s.id !== 'string') return; - if (s?.metadata?.systemSessionV1?.hidden === true) return; + if (isHiddenSystemSession({ metadata: s?.metadata })) return; if (seen.has(s.id)) return; seen.add(s.id); const updatedAt = typeof s.updatedAt === 'number' ? s.updatedAt : 0; @@ -103,4 +104,3 @@ export async function listSessionsForVoiceTool(params: Readonly<{ return { ok: true, sessions, nextCursor }; } - diff --git a/apps/ui/tools/ensureNohoistPeerLinks.mjs b/apps/ui/tools/ensureNohoistPeerLinks.mjs new file mode 100644 index 000000000..72f884b21 --- /dev/null +++ b/apps/ui/tools/ensureNohoistPeerLinks.mjs @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Yarn workspace `nohoist` keeps React/React Native dependencies under the Expo app workspace. + * Some tooling executed from hoisted packages (e.g. expo-dev-launcher during EAS local builds) + * expects to resolve these peer deps from the repo root `node_modules`. + * + * We bridge that gap by creating root-level symlinks to the nohoisted workspace installs. + * + * @param {{ repoRootDir: string; expoAppDir: string }} opts + */ +export function ensureNohoistPeerLinks(opts) { + const repoRootDir = path.resolve(opts.repoRootDir); + const expoAppDir = path.resolve(opts.expoAppDir); + + const repoRootNodeModulesDir = path.join(repoRootDir, 'node_modules'); + const expoAppNodeModulesDir = path.join(expoAppDir, 'node_modules'); + + if (!fs.existsSync(repoRootNodeModulesDir) || !fs.existsSync(expoAppNodeModulesDir)) return; + + const packages = ['react', 'react-dom', 'react-native']; + for (const pkg of packages) { + const target = path.join(expoAppNodeModulesDir, pkg); + const link = path.join(repoRootNodeModulesDir, pkg); + + if (fs.existsSync(link)) continue; + if (!fs.existsSync(target)) continue; + + try { + fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Unable to create root nohoist peer link for '${pkg}': ${message}`); + } + } +} + diff --git a/apps/ui/tools/migrations/maps/styledText.move.json b/apps/ui/tools/migrations/maps/styledText.move.json new file mode 100644 index 000000000..988e3f497 --- /dev/null +++ b/apps/ui/tools/migrations/maps/styledText.move.json @@ -0,0 +1,9 @@ +{ + "moves": [ + { + "from": "apps/ui/sources/components/ui/text/StyledText.tsx", + "to": "apps/ui/sources/components/ui/text/Text.tsx" + } + ] +} + diff --git a/apps/ui/tools/migrations/rewrite-text-mocks.ts b/apps/ui/tools/migrations/rewrite-text-mocks.ts new file mode 100644 index 000000000..c49780ad4 --- /dev/null +++ b/apps/ui/tools/migrations/rewrite-text-mocks.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = path.resolve(__dirname, '../../../..'); +const sourcesRoot = path.join(repoRoot, 'apps/ui/sources'); + +function toPosix(p: string): string { + return p.split(path.sep).join('/'); +} + +function isTestFile(rel: string): boolean { + return /\.(spec|test)\.[tj]sx?$/.test(rel); +} + +function walk(dir: string, out: string[]): void { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.name.startsWith('.')) continue; + if (ent.name === 'node_modules') continue; + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + walk(full, out); + continue; + } + out.push(full); + } +} + +function rewriteFile(text: string): { next: string; changed: boolean } { + const marker = "vi.mock('@/components/ui/text/Text', () => ({"; + let idx = 0; + let next = text; + let changed = false; + + while (true) { + const start = next.indexOf(marker, idx); + if (start === -1) break; + + const blockStart = start; + const blockEnd = next.indexOf('}));', blockStart); + if (blockEnd === -1) break; + + const block = next.slice(blockStart, blockEnd + 4); + if (block.includes('TextInput')) { + idx = blockEnd + 4; + continue; + } + + const textLineMatch = block.match(/^[ \t]*Text\s*:\s*.+$/m); + if (!textLineMatch) { + idx = blockEnd + 4; + continue; + } + + const textLine = textLineMatch[0]; + const indent = (textLine.match(/^[ \t]*/) ?? [''])[0]; + const insertion = `${indent}TextInput: 'TextInput',\n`; + + const lineIndex = block.indexOf(textLine); + const afterLineIndex = block.indexOf('\n', lineIndex); + if (afterLineIndex === -1) { + idx = blockEnd + 4; + continue; + } + + const newBlock = `${block.slice(0, afterLineIndex + 1)}${insertion}${block.slice(afterLineIndex + 1)}`; + next = `${next.slice(0, blockStart)}${newBlock}${next.slice(blockEnd + 4)}`; + changed = true; + idx = blockStart + newBlock.length; + } + + return { next, changed }; +} + +const files: string[] = []; +walk(sourcesRoot, files); + +let rewrote = 0; +for (const abs of files) { + const rel = toPosix(path.relative(repoRoot, abs)); + if (!rel.startsWith('apps/ui/sources/')) continue; + if (!isTestFile(rel)) continue; + + const text = fs.readFileSync(abs, 'utf8'); + const { next, changed } = rewriteFile(text); + if (!changed) continue; + fs.writeFileSync(abs, next, 'utf8'); + rewrote += 1; + console.log(`REWROTE: ${rel}`); +} + +console.log(`Rewrote Text mocks in ${rewrote} files.`); + diff --git a/apps/ui/tools/migrations/rewrite-text-primitives.ts b/apps/ui/tools/migrations/rewrite-text-primitives.ts new file mode 100644 index 000000000..066c87596 --- /dev/null +++ b/apps/ui/tools/migrations/rewrite-text-primitives.ts @@ -0,0 +1,232 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const repoRoot = path.resolve(__dirname, '../../../..'); +const sourcesRoot = path.join(repoRoot, 'apps/ui/sources'); + +const APP_TEXT_MODULE = '@/components/ui/text/Text'; +const LEGACY_TEXT_MODULE = '@/components/ui/text/StyledText'; + +function isTsLike(filePath: string): boolean { + return /\.(ts|tsx|mts|cts|js|jsx)$/.test(filePath); +} + +function shouldSkipFile(filePath: string): boolean { + const rel = toPosix(path.relative(repoRoot, filePath)); + if (!rel.startsWith('apps/ui/sources/')) return true; + if (rel.includes('/node_modules/')) return true; + if (/\.(spec|test)\.[tj]sx?$/.test(rel)) return true; + if (rel.includes('/__tests__/')) return true; + if (rel.includes('/sources/dev/')) return true; + if (rel.includes('/sources/app/(app)/dev/')) return true; + if (rel === 'apps/ui/sources/components/ui/text/Text.tsx') return true; + return false; +} + +function toPosix(p: string): string { + return p.split(path.sep).join('/'); +} + +function walk(dir: string, out: string[]): void { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.name.startsWith('.')) continue; + if (ent.name === 'node_modules') continue; + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + walk(full, out); + continue; + } + if (isTsLike(full)) out.push(full); + } +} + +type Replacement = { start: number; end: number; value: string }; + +function replaceWithRanges(text: string, ranges: Replacement[]): string { + if (ranges.length === 0) return text; + ranges.sort((a, b) => b.start - a.start); + let next = text; + for (const r of ranges) { + next = `${next.slice(0, r.start)}${r.value}${next.slice(r.end)}`; + } + return next; +} + +type NamedImport = Readonly<{ name: string; alias?: string | null }>; + +function printNamedImports(names: ReadonlyArray<NamedImport>): string { + if (names.length === 0) return ''; + const entries = names.map((n) => (n.alias ? `${n.name} as ${n.alias}` : n.name)); + return `{ ${entries.join(', ')} }`; +} + +function collectNamedImports(node: ts.ImportDeclaration): { defaultName: string | null; namespaceName: string | null; named: NamedImport[]; isTypeOnly: boolean } { + const clause = node.importClause; + if (!clause) return { defaultName: null, namespaceName: null, named: [], isTypeOnly: false }; + + const defaultName = clause.name ? clause.name.text : null; + const isTypeOnly = Boolean(clause.isTypeOnly); + + const bindings = clause.namedBindings; + if (!bindings) return { defaultName, namespaceName: null, named: [], isTypeOnly }; + + if (ts.isNamespaceImport(bindings)) { + return { defaultName, namespaceName: bindings.name.text, named: [], isTypeOnly }; + } + + const named = bindings.elements.map((el) => ({ + name: el.propertyName ? el.propertyName.text : el.name.text, + alias: el.propertyName ? el.name.text : null, + })); + + return { defaultName, namespaceName: null, named, isTypeOnly }; +} + +function buildImportLine(args: { defaultName: string | null; namespaceName: string | null; named: NamedImport[]; module: string; isTypeOnly: boolean }): string | null { + const parts: string[] = []; + if (args.defaultName) parts.push(args.defaultName); + if (args.namespaceName) parts.push(`* as ${args.namespaceName}`); + if (args.named.length > 0) parts.push(printNamedImports(args.named)); + + if (parts.length === 0) return null; + const typePrefix = args.isTypeOnly ? 'import type ' : 'import '; + return `${typePrefix}${parts.join(', ')} from '${args.module}';`; +} + +function uniqNamedImports(named: NamedImport[]): NamedImport[] { + const seen = new Map<string, NamedImport>(); + for (const n of named) { + const key = `${n.name}:${n.alias ?? ''}`; + if (!seen.has(key)) seen.set(key, n); + } + return Array.from(seen.values()).sort((a, b) => { + const aKey = `${a.name}:${a.alias ?? ''}`; + const bKey = `${b.name}:${b.alias ?? ''}`; + return aKey.localeCompare(bKey); + }); +} + +function collectReplacements(filePath: string, text: string): Replacement[] { + const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + const replacements: Replacement[] = []; + + const imports = sf.statements.filter(ts.isImportDeclaration); + const rnImports = imports.filter((d) => ts.isStringLiteral(d.moduleSpecifier) && d.moduleSpecifier.text === 'react-native'); + const appTextImports = imports.filter((d) => ts.isStringLiteral(d.moduleSpecifier) && (d.moduleSpecifier.text === APP_TEXT_MODULE || d.moduleSpecifier.text === LEGACY_TEXT_MODULE)); + + let needsText: NamedImport | null = null; + let needsTextInput: NamedImport | null = null; + + // 1) Update react-native imports to remove Text/TextInput (value imports only). + for (const decl of rnImports) { + const parsed = collectNamedImports(decl); + if (parsed.isTypeOnly) continue; + if (parsed.namespaceName) continue; + const originalNamed = parsed.named; + if (originalNamed.length === 0) continue; + + const kept: NamedImport[] = []; + for (const n of originalNamed) { + if (n.name === 'Text') { + needsText = needsText ?? { name: 'Text', alias: n.alias ?? null }; + continue; + } + if (n.name === 'TextInput') { + needsTextInput = needsTextInput ?? { name: 'TextInput', alias: n.alias ?? null }; + continue; + } + kept.push(n); + } + + if (kept.length === originalNamed.length) continue; + + const nextLine = buildImportLine({ + defaultName: parsed.defaultName, + namespaceName: parsed.namespaceName, + named: kept, + module: 'react-native', + isTypeOnly: parsed.isTypeOnly, + }); + + replacements.push({ + start: decl.getStart(sf), + end: decl.getEnd(), + value: nextLine ? nextLine : '', + }); + } + + // 2) Ensure we import Text/TextInput from the app primitive module (rewrite legacy specifier too). + const appDecl = appTextImports.length > 0 ? appTextImports[0] : null; + if (appDecl) { + const parsed = collectNamedImports(appDecl); + const named = [...parsed.named]; + + if (needsText && !named.some((n) => n.name === 'Text' && (n.alias ?? null) === (needsText!.alias ?? null))) { + named.push(needsText); + } + if (needsTextInput && !named.some((n) => n.name === 'TextInput' && (n.alias ?? null) === (needsTextInput!.alias ?? null))) { + named.push(needsTextInput); + } + + const nextModule = APP_TEXT_MODULE; + const nextLine = buildImportLine({ + defaultName: parsed.defaultName, + namespaceName: parsed.namespaceName, + named: uniqNamedImports(named), + module: nextModule, + isTypeOnly: parsed.isTypeOnly, + }); + + // Replace the entire import declaration for stability (module + bindings). + replacements.push({ + start: appDecl.getStart(sf), + end: appDecl.getEnd(), + value: nextLine ?? '', + }); + } else if (needsText || needsTextInput) { + const named: NamedImport[] = []; + if (needsText) named.push(needsText); + if (needsTextInput) named.push(needsTextInput); + + const line = buildImportLine({ + defaultName: null, + namespaceName: null, + named: uniqNamedImports(named), + module: APP_TEXT_MODULE, + isTypeOnly: false, + }); + + if (line) { + const lastImport = imports.length > 0 ? imports[imports.length - 1] : null; + const insertAt = lastImport ? lastImport.getEnd() : 0; + const prefix = lastImport ? '\n' : ''; + replacements.push({ + start: insertAt, + end: insertAt, + value: `${prefix}${line}\n`, + }); + } + } + + return replacements; +} + +const files: string[] = []; +walk(sourcesRoot, files); + +let changed = 0; +for (const abs of files) { + if (shouldSkipFile(abs)) continue; + const text = fs.readFileSync(abs, 'utf8'); + const replacements = collectReplacements(abs, text); + if (replacements.length === 0) continue; + const next = replaceWithRanges(text, replacements); + if (next !== text) { + fs.writeFileSync(abs, next, 'utf8'); + changed += 1; + console.log(`REWROTE: ${toPosix(path.relative(repoRoot, abs))}`); + } +} + +console.log(`Rewrote text primitives in ${changed} files.`); diff --git a/apps/ui/tools/postinstall.mjs b/apps/ui/tools/postinstall.mjs index cb68d19d7..cf8e96a54 100644 --- a/apps/ui/tools/postinstall.mjs +++ b/apps/ui/tools/postinstall.mjs @@ -4,6 +4,7 @@ import path from 'node:path'; import process from 'node:process'; import url from 'node:url'; import { resolveUiPostinstallTasks } from './resolveUiPostinstallTasks.mjs'; +import { ensureNohoistPeerLinks } from './ensureNohoistPeerLinks.mjs'; // Yarn workspaces can execute this script via a symlinked path (e.g. repoRoot/node_modules/happy/...). // Resolve symlinks so repoRootDir/expoAppDir are computed from the real filesystem location. @@ -34,6 +35,13 @@ const patchDirFromExpoApp = path.relative(expoAppDir, patchDir); const repoRootNodeModulesDir = path.resolve(repoRootDir, 'node_modules'); const expoAppNodeModulesDir = path.resolve(expoAppDir, 'node_modules'); +try { + ensureNohoistPeerLinks({ repoRootDir, expoAppDir }); +} catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); +} + const patchPackageCliCandidatePaths = [ path.resolve(expoAppDir, 'node_modules', 'patch-package', 'dist', 'index.js'), path.resolve(repoRootDir, 'node_modules', 'patch-package', 'dist', 'index.js'), diff --git a/apps/website/public/install b/apps/website/public/install index 29ced9f28..0ac14bd40 100644 --- a/apps/website/public/install +++ b/apps/website/public/install @@ -8,6 +8,9 @@ BIN_DIR="${HAPPIER_BIN_DIR:-$HOME/.local/bin}" WITH_DAEMON="${HAPPIER_WITH_DAEMON:-1}" NO_PATH_UPDATE="${HAPPIER_NO_PATH_UPDATE:-0}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_INSTALL_DIR="${HAPPIER_INSTALLER_PURGE:-0}" GITHUB_REPO="${HAPPIER_GITHUB_REPO:-happier-dev/happier}" GITHUB_TOKEN="${HAPPIER_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}" DEFAULT_MINISIGN_PUBKEY="$(cat <<'EOF' @@ -19,87 +22,60 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" -usage() { - cat <<'EOF' -Usage: - curl -fsSL https://happier.dev/install | bash +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never -Preview channel: - curl -fsSL https://happier.dev/install | bash -s -- --channel preview - curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash - curl -fsSL https://happier.dev/install-preview | bash +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} -Options: - --channel <stable|preview> - --stable - --preview - --with-daemon - --without-daemon - -h, --help -EOF +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" } -while [[ $# -gt 0 ]]; do - case "$1" in - --channel) - if [[ $# -lt 2 || -z "${2:-}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - CHANNEL="${2}" - shift 2 - ;; - --channel=*) - CHANNEL="${1#*=}" - if [[ -z "${CHANNEL}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - shift 1 - ;; - --stable) - CHANNEL="stable" - shift 1 - ;; - --preview) - CHANNEL="preview" - shift 1 - ;; - --with-daemon) - WITH_DAEMON="1" - shift 1 - ;; - --without-daemon) - WITH_DAEMON="0" - shift 1 - ;; - -h|--help) - usage - exit 0 - ;; - --) - shift 1 - break - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} -if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then - echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 - exit 1 -fi +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} -if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then - echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 - exit 1 -fi +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +shell_command_cache_hint() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + if [[ "${shell_name}" == "zsh" ]]; then + say " rehash" + else + say " hash -r" + fi +} detect_os() { case "$(uname -s)" in @@ -175,6 +151,387 @@ json_lookup_asset_url() { ' } +resolve_exe_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "happier-server" + else + echo "happier" + fi +} + +resolve_install_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "Happier Server" + else + echo "Happier CLI" + fi +} + +resolve_installed_binary() { + local exe + exe="$(resolve_exe_name)" + local candidate="${INSTALL_DIR}/bin/${exe}" + if [[ -x "${candidate}" ]]; then + printf '%s' "${candidate}" + return 0 + fi + local from_path + from_path="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${from_path}" ]] && [[ -x "${from_path}" ]]; then + printf '%s' "${from_path}" + return 0 + fi + return 1 +} + +action_check() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local ok="1" + local binary_path="${INSTALL_DIR}/bin/${exe}" + local shim_path="${BIN_DIR}/${exe}" + + info "${name} check" + say "- product: ${PRODUCT}" + say "- binary: ${binary_path}" + say "- shim: ${shim_path}" + + if [[ ! -x "${binary_path}" ]]; then + warn "Missing binary: ${binary_path}" + ok="0" + fi + + if [[ ! -e "${shim_path}" ]]; then + warn "Missing shim: ${shim_path}" + fi + + local resolved="" + resolved="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${resolved}" ]]; then + say "- command: ${resolved}" + else + warn "Command not found on PATH: ${exe}" + fi + + local resolved_binary="" + resolved_binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${resolved_binary}" ]]; then + local version_out="" + version_out="$("${resolved_binary}" --version 2>/dev/null || true)" + if [[ -n "${version_out}" ]]; then + say "- version: ${version_out}" + else + warn "Failed to execute: ${resolved_binary}" + ok="0" + fi + fi + + if command -v file >/dev/null 2>&1 && [[ -x "${binary_path}" ]]; then + say + say "file:" + file "${binary_path}" || true + fi + if command -v xattr >/dev/null 2>&1 && [[ -e "${binary_path}" ]]; then + say + say "xattr:" + xattr -l "${binary_path}" 2>/dev/null || true + fi + + say + say "Shell tip (if PATH changed in this session):" + shell_command_cache_hint + + if [[ "${ok}" == "1" ]]; then + success "OK" + return 0 + fi + warn "${name} is not installed correctly." + return 1 +} + +action_restart() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -z "${binary}" ]]; then + warn "${name} is not installed." + return 1 + fi + if [[ "${PRODUCT}" != "cli" ]]; then + warn "Restart is only supported for the CLI daemon." + return 1 + fi + + info "Restarting daemon service (best-effort)..." + if ! "${binary}" daemon service restart >/dev/null 2>&1; then + warn "Daemon service restart failed (it may not be installed)." + warn "Try: ${binary} daemon service install" + return 1 + fi + success "Daemon service restarted." + return 0 +} + +action_uninstall() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${binary}" && "${PRODUCT}" == "cli" ]]; then + "${binary}" daemon service uninstall >/dev/null 2>&1 || true + fi + + rm -f "${BIN_DIR}/${exe}" "${INSTALL_DIR}/bin/${exe}.new" "${INSTALL_DIR}/bin/${exe}.previous" || true + rm -f "${INSTALL_DIR}/bin/${exe}" || true + if [[ "${PURGE_INSTALL_DIR}" == "1" ]]; then + rm -rf "${INSTALL_DIR}" || true + fi + + success "${name} uninstalled." + say "Tip: if your shell still can't find changes, run:" + shell_command_cache_hint + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + +action_version() { + local name + name="$(resolve_install_name)" + + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local os="" + local arch="" + os="$(detect_os)" + arch="$(detect_arch)" + if [[ "${os}" == "unsupported" || "${arch}" == "unsupported" ]]; then + echo "Unsupported platform: $(uname -s)/$(uname -m)" >&2 + return 1 + fi + + local tag="cli-stable" + local asset_regex="^happier-v.*-${os}-${arch}[.]tar[.]gz$" + local version_prefix="happier-v" + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-stable" + asset_regex="^happier-server-v.*-${os}-${arch}[.]tar[.]gz$" + version_prefix="happier-server-v" + fi + if [[ "${CHANNEL}" == "preview" ]]; then + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-preview" + else + tag="cli-preview" + fi + fi + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + curl_auth() { + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + curl -fsSL \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + return + fi + curl -fsSL "$@" + } + + local release_json="" + if ! release_json="$(curl_auth "${api_url}")"; then + echo "Failed to fetch release metadata for ${name}." >&2 + return 1 + fi + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${OS}-${ARCH} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#${version_prefix}}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "${name} installer version check" + say "- channel: ${CHANNEL}" + say "- product: ${PRODUCT}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +usage() { + cat <<'EOF' +Usage: + curl -fsSL https://happier.dev/install | bash + +Preview channel: + curl -fsSL https://happier.dev/install | bash -s -- --channel preview + curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash + curl -fsSL https://happier.dev/install-preview | bash + +Options: + --channel <stable|preview> + --stable + --preview + --with-daemon + --without-daemon + --check + --version + --reinstall + --restart + --uninstall [--purge] + --reset + --debug + -h, --help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --channel) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + CHANNEL="${2}" + shift 2 + ;; + --channel=*) + CHANNEL="${1#*=}" + if [[ -z "${CHANNEL}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + shift 1 + ;; + --stable) + CHANNEL="stable" + shift 1 + ;; + --preview) + CHANNEL="preview" + shift 1 + ;; + --with-daemon) + WITH_DAEMON="1" + shift 1 + ;; + --without-daemon) + WITH_DAEMON="0" + shift 1 + ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --purge) + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift 1 + break + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + +if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then + echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 + exit 1 +fi + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi + +if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -225,7 +582,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else # Prefer built-in macOS tooling to avoid requiring unzip. if command -v ditto >/dev/null 2>&1; then @@ -292,16 +649,58 @@ append_path_hint() { fi local shell_name shell_name="$(basename "${SHELL:-}")" - local rc_file + local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" + local rc_files=() case "${shell_name}" in - zsh) rc_file="$HOME/.zshrc" ;; - bash) rc_file="$HOME/.bashrc" ;; - *) rc_file="$HOME/.profile" ;; + zsh) + rc_files+=("$HOME/.zshrc") + rc_files+=("$HOME/.zprofile") + ;; + bash) + rc_files+=("$HOME/.bashrc") + if [[ -f "$HOME/.bash_profile" ]]; then + rc_files+=("$HOME/.bash_profile") + else + rc_files+=("$HOME/.profile") + fi + ;; + *) + rc_files+=("$HOME/.profile") + ;; esac - local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" - if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then - printf '\n%s\n' "${export_line}" >> "${rc_file}" - echo "Added ${BIN_DIR} to PATH in ${rc_file}" + + local updated=0 + for rc_file in "${rc_files[@]}"; do + if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then + printf '\n%s\n' "${export_line}" >> "${rc_file}" + info "Added ${BIN_DIR} to PATH in ${rc_file}" + updated=1 + fi + done + + if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then + echo + say "${COLOR_BOLD}Next steps${COLOR_RESET}" + say "To use ${EXE_NAME} in your current shell:" + say " export PATH=\"${BIN_DIR}:\$PATH\"" + if [[ "${shell_name}" == "bash" ]]; then + say " source \"$HOME/.bashrc\"" + if [[ -f "$HOME/.bash_profile" ]]; then + say " source \"$HOME/.bash_profile\"" + else + say " source \"$HOME/.profile\"" + fi + elif [[ "${shell_name}" == "zsh" ]]; then + say " source \"$HOME/.zshrc\"" + else + say " source \"$HOME/.profile\"" + fi + say "If your shell still can't find ${EXE_NAME}, run:" + shell_command_cache_hint + say "Or open a new terminal." + elif [[ "${updated}" == "1" ]]; then + echo + say "PATH is already configured in this shell." fi } @@ -350,7 +749,7 @@ if [[ "${CHANNEL}" == "preview" ]]; then fi API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." curl_auth() { if [[ -n "${GITHUB_TOKEN:-}" ]]; then curl -fsSL \ @@ -396,6 +795,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -415,7 +817,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -428,11 +830,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl_auth -o "${SIG_PATH}" "${SIG_URL}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name "${EXE_NAME}" -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then @@ -441,15 +843,25 @@ if [[ -z "${BINARY_PATH}" ]]; then fi mkdir -p "${INSTALL_DIR}/bin" "${BIN_DIR}" -cp "${BINARY_PATH}" "${INSTALL_DIR}/bin/${EXE_NAME}" -chmod +x "${INSTALL_DIR}/bin/${EXE_NAME}" +TARGET_BIN="${INSTALL_DIR}/bin/${EXE_NAME}" +STAGED_BIN="${TARGET_BIN}.new" +PREVIOUS_BIN="${TARGET_BIN}.previous" +cp "${BINARY_PATH}" "${STAGED_BIN}" +chmod +x "${STAGED_BIN}" +if [[ -f "${TARGET_BIN}" ]]; then + cp "${TARGET_BIN}" "${PREVIOUS_BIN}" >/dev/null 2>&1 || true + chmod +x "${PREVIOUS_BIN}" >/dev/null 2>&1 || true +fi +# Avoid ETXTBSY when replacing a running executable: swap the directory entry atomically. +mv -f "${STAGED_BIN}" "${TARGET_BIN}" +chmod +x "${TARGET_BIN}" ln -sf "${INSTALL_DIR}/bin/${EXE_NAME}" "${BIN_DIR}/${EXE_NAME}" append_path_hint if [[ "${PRODUCT}" == "cli" && "${WITH_DAEMON}" == "1" ]]; then echo - echo "Installing daemon service (user-mode)..." + info "Installing daemon service (user-mode)..." if ! "${INSTALL_DIR}/bin/${EXE_NAME}" daemon service install >/dev/null 2>&1; then echo "Warning: daemon service install failed. You can retry manually:" >&2 echo " ${INSTALL_DIR}/bin/${EXE_NAME} daemon service install" >&2 diff --git a/apps/website/public/install-preview b/apps/website/public/install-preview index 9d0c29876..a099e2124 100644 --- a/apps/website/public/install-preview +++ b/apps/website/public/install-preview @@ -8,6 +8,9 @@ BIN_DIR="${HAPPIER_BIN_DIR:-$HOME/.local/bin}" WITH_DAEMON="${HAPPIER_WITH_DAEMON:-1}" NO_PATH_UPDATE="${HAPPIER_NO_PATH_UPDATE:-0}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_INSTALL_DIR="${HAPPIER_INSTALLER_PURGE:-0}" GITHUB_REPO="${HAPPIER_GITHUB_REPO:-happier-dev/happier}" GITHUB_TOKEN="${HAPPIER_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}" DEFAULT_MINISIGN_PUBKEY="$(cat <<'EOF' @@ -19,87 +22,60 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" -usage() { - cat <<'EOF' -Usage: - curl -fsSL https://happier.dev/install | bash +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never -Preview channel: - curl -fsSL https://happier.dev/install | bash -s -- --channel preview - curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash - curl -fsSL https://happier.dev/install-preview | bash +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} -Options: - --channel <stable|preview> - --stable - --preview - --with-daemon - --without-daemon - -h, --help -EOF +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" } -while [[ $# -gt 0 ]]; do - case "$1" in - --channel) - if [[ $# -lt 2 || -z "${2:-}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - CHANNEL="${2}" - shift 2 - ;; - --channel=*) - CHANNEL="${1#*=}" - if [[ -z "${CHANNEL}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - shift 1 - ;; - --stable) - CHANNEL="stable" - shift 1 - ;; - --preview) - CHANNEL="preview" - shift 1 - ;; - --with-daemon) - WITH_DAEMON="1" - shift 1 - ;; - --without-daemon) - WITH_DAEMON="0" - shift 1 - ;; - -h|--help) - usage - exit 0 - ;; - --) - shift 1 - break - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} -if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then - echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 - exit 1 -fi +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} -if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then - echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 - exit 1 -fi +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +shell_command_cache_hint() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + if [[ "${shell_name}" == "zsh" ]]; then + say " rehash" + else + say " hash -r" + fi +} detect_os() { case "$(uname -s)" in @@ -175,6 +151,387 @@ json_lookup_asset_url() { ' } +resolve_exe_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "happier-server" + else + echo "happier" + fi +} + +resolve_install_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "Happier Server" + else + echo "Happier CLI" + fi +} + +resolve_installed_binary() { + local exe + exe="$(resolve_exe_name)" + local candidate="${INSTALL_DIR}/bin/${exe}" + if [[ -x "${candidate}" ]]; then + printf '%s' "${candidate}" + return 0 + fi + local from_path + from_path="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${from_path}" ]] && [[ -x "${from_path}" ]]; then + printf '%s' "${from_path}" + return 0 + fi + return 1 +} + +action_check() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local ok="1" + local binary_path="${INSTALL_DIR}/bin/${exe}" + local shim_path="${BIN_DIR}/${exe}" + + info "${name} check" + say "- product: ${PRODUCT}" + say "- binary: ${binary_path}" + say "- shim: ${shim_path}" + + if [[ ! -x "${binary_path}" ]]; then + warn "Missing binary: ${binary_path}" + ok="0" + fi + + if [[ ! -e "${shim_path}" ]]; then + warn "Missing shim: ${shim_path}" + fi + + local resolved="" + resolved="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${resolved}" ]]; then + say "- command: ${resolved}" + else + warn "Command not found on PATH: ${exe}" + fi + + local resolved_binary="" + resolved_binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${resolved_binary}" ]]; then + local version_out="" + version_out="$("${resolved_binary}" --version 2>/dev/null || true)" + if [[ -n "${version_out}" ]]; then + say "- version: ${version_out}" + else + warn "Failed to execute: ${resolved_binary}" + ok="0" + fi + fi + + if command -v file >/dev/null 2>&1 && [[ -x "${binary_path}" ]]; then + say + say "file:" + file "${binary_path}" || true + fi + if command -v xattr >/dev/null 2>&1 && [[ -e "${binary_path}" ]]; then + say + say "xattr:" + xattr -l "${binary_path}" 2>/dev/null || true + fi + + say + say "Shell tip (if PATH changed in this session):" + shell_command_cache_hint + + if [[ "${ok}" == "1" ]]; then + success "OK" + return 0 + fi + warn "${name} is not installed correctly." + return 1 +} + +action_restart() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -z "${binary}" ]]; then + warn "${name} is not installed." + return 1 + fi + if [[ "${PRODUCT}" != "cli" ]]; then + warn "Restart is only supported for the CLI daemon." + return 1 + fi + + info "Restarting daemon service (best-effort)..." + if ! "${binary}" daemon service restart >/dev/null 2>&1; then + warn "Daemon service restart failed (it may not be installed)." + warn "Try: ${binary} daemon service install" + return 1 + fi + success "Daemon service restarted." + return 0 +} + +action_uninstall() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${binary}" && "${PRODUCT}" == "cli" ]]; then + "${binary}" daemon service uninstall >/dev/null 2>&1 || true + fi + + rm -f "${BIN_DIR}/${exe}" "${INSTALL_DIR}/bin/${exe}.new" "${INSTALL_DIR}/bin/${exe}.previous" || true + rm -f "${INSTALL_DIR}/bin/${exe}" || true + if [[ "${PURGE_INSTALL_DIR}" == "1" ]]; then + rm -rf "${INSTALL_DIR}" || true + fi + + success "${name} uninstalled." + say "Tip: if your shell still can't find changes, run:" + shell_command_cache_hint + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + +action_version() { + local name + name="$(resolve_install_name)" + + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local os="" + local arch="" + os="$(detect_os)" + arch="$(detect_arch)" + if [[ "${os}" == "unsupported" || "${arch}" == "unsupported" ]]; then + echo "Unsupported platform: $(uname -s)/$(uname -m)" >&2 + return 1 + fi + + local tag="cli-stable" + local asset_regex="^happier-v.*-${os}-${arch}[.]tar[.]gz$" + local version_prefix="happier-v" + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-stable" + asset_regex="^happier-server-v.*-${os}-${arch}[.]tar[.]gz$" + version_prefix="happier-server-v" + fi + if [[ "${CHANNEL}" == "preview" ]]; then + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-preview" + else + tag="cli-preview" + fi + fi + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + curl_auth() { + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + curl -fsSL \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + return + fi + curl -fsSL "$@" + } + + local release_json="" + if ! release_json="$(curl_auth "${api_url}")"; then + echo "Failed to fetch release metadata for ${name}." >&2 + return 1 + fi + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${OS}-${ARCH} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#${version_prefix}}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "${name} installer version check" + say "- channel: ${CHANNEL}" + say "- product: ${PRODUCT}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +usage() { + cat <<'EOF' +Usage: + curl -fsSL https://happier.dev/install | bash + +Preview channel: + curl -fsSL https://happier.dev/install | bash -s -- --channel preview + curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash + curl -fsSL https://happier.dev/install-preview | bash + +Options: + --channel <stable|preview> + --stable + --preview + --with-daemon + --without-daemon + --check + --version + --reinstall + --restart + --uninstall [--purge] + --reset + --debug + -h, --help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --channel) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + CHANNEL="${2}" + shift 2 + ;; + --channel=*) + CHANNEL="${1#*=}" + if [[ -z "${CHANNEL}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + shift 1 + ;; + --stable) + CHANNEL="stable" + shift 1 + ;; + --preview) + CHANNEL="preview" + shift 1 + ;; + --with-daemon) + WITH_DAEMON="1" + shift 1 + ;; + --without-daemon) + WITH_DAEMON="0" + shift 1 + ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --purge) + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift 1 + break + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + +if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then + echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 + exit 1 +fi + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi + +if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -225,7 +582,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else # Prefer built-in macOS tooling to avoid requiring unzip. if command -v ditto >/dev/null 2>&1; then @@ -292,16 +649,58 @@ append_path_hint() { fi local shell_name shell_name="$(basename "${SHELL:-}")" - local rc_file + local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" + local rc_files=() case "${shell_name}" in - zsh) rc_file="$HOME/.zshrc" ;; - bash) rc_file="$HOME/.bashrc" ;; - *) rc_file="$HOME/.profile" ;; + zsh) + rc_files+=("$HOME/.zshrc") + rc_files+=("$HOME/.zprofile") + ;; + bash) + rc_files+=("$HOME/.bashrc") + if [[ -f "$HOME/.bash_profile" ]]; then + rc_files+=("$HOME/.bash_profile") + else + rc_files+=("$HOME/.profile") + fi + ;; + *) + rc_files+=("$HOME/.profile") + ;; esac - local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" - if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then - printf '\n%s\n' "${export_line}" >> "${rc_file}" - echo "Added ${BIN_DIR} to PATH in ${rc_file}" + + local updated=0 + for rc_file in "${rc_files[@]}"; do + if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then + printf '\n%s\n' "${export_line}" >> "${rc_file}" + info "Added ${BIN_DIR} to PATH in ${rc_file}" + updated=1 + fi + done + + if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then + echo + say "${COLOR_BOLD}Next steps${COLOR_RESET}" + say "To use ${EXE_NAME} in your current shell:" + say " export PATH=\"${BIN_DIR}:\$PATH\"" + if [[ "${shell_name}" == "bash" ]]; then + say " source \"$HOME/.bashrc\"" + if [[ -f "$HOME/.bash_profile" ]]; then + say " source \"$HOME/.bash_profile\"" + else + say " source \"$HOME/.profile\"" + fi + elif [[ "${shell_name}" == "zsh" ]]; then + say " source \"$HOME/.zshrc\"" + else + say " source \"$HOME/.profile\"" + fi + say "If your shell still can't find ${EXE_NAME}, run:" + shell_command_cache_hint + say "Or open a new terminal." + elif [[ "${updated}" == "1" ]]; then + echo + say "PATH is already configured in this shell." fi } @@ -350,7 +749,7 @@ if [[ "${CHANNEL}" == "preview" ]]; then fi API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." curl_auth() { if [[ -n "${GITHUB_TOKEN:-}" ]]; then curl -fsSL \ @@ -396,6 +795,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -415,7 +817,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -428,11 +830,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl_auth -o "${SIG_PATH}" "${SIG_URL}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name "${EXE_NAME}" -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then @@ -441,15 +843,25 @@ if [[ -z "${BINARY_PATH}" ]]; then fi mkdir -p "${INSTALL_DIR}/bin" "${BIN_DIR}" -cp "${BINARY_PATH}" "${INSTALL_DIR}/bin/${EXE_NAME}" -chmod +x "${INSTALL_DIR}/bin/${EXE_NAME}" +TARGET_BIN="${INSTALL_DIR}/bin/${EXE_NAME}" +STAGED_BIN="${TARGET_BIN}.new" +PREVIOUS_BIN="${TARGET_BIN}.previous" +cp "${BINARY_PATH}" "${STAGED_BIN}" +chmod +x "${STAGED_BIN}" +if [[ -f "${TARGET_BIN}" ]]; then + cp "${TARGET_BIN}" "${PREVIOUS_BIN}" >/dev/null 2>&1 || true + chmod +x "${PREVIOUS_BIN}" >/dev/null 2>&1 || true +fi +# Avoid ETXTBSY when replacing a running executable: swap the directory entry atomically. +mv -f "${STAGED_BIN}" "${TARGET_BIN}" +chmod +x "${TARGET_BIN}" ln -sf "${INSTALL_DIR}/bin/${EXE_NAME}" "${BIN_DIR}/${EXE_NAME}" append_path_hint if [[ "${PRODUCT}" == "cli" && "${WITH_DAEMON}" == "1" ]]; then echo - echo "Installing daemon service (user-mode)..." + info "Installing daemon service (user-mode)..." if ! "${INSTALL_DIR}/bin/${EXE_NAME}" daemon service install >/dev/null 2>&1; then echo "Warning: daemon service install failed. You can retry manually:" >&2 echo " ${INSTALL_DIR}/bin/${EXE_NAME} daemon service install" >&2 diff --git a/apps/website/public/install-preview.sh b/apps/website/public/install-preview.sh index 9d0c29876..a099e2124 100644 --- a/apps/website/public/install-preview.sh +++ b/apps/website/public/install-preview.sh @@ -8,6 +8,9 @@ BIN_DIR="${HAPPIER_BIN_DIR:-$HOME/.local/bin}" WITH_DAEMON="${HAPPIER_WITH_DAEMON:-1}" NO_PATH_UPDATE="${HAPPIER_NO_PATH_UPDATE:-0}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_INSTALL_DIR="${HAPPIER_INSTALLER_PURGE:-0}" GITHUB_REPO="${HAPPIER_GITHUB_REPO:-happier-dev/happier}" GITHUB_TOKEN="${HAPPIER_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}" DEFAULT_MINISIGN_PUBKEY="$(cat <<'EOF' @@ -19,87 +22,60 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" -usage() { - cat <<'EOF' -Usage: - curl -fsSL https://happier.dev/install | bash +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never -Preview channel: - curl -fsSL https://happier.dev/install | bash -s -- --channel preview - curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash - curl -fsSL https://happier.dev/install-preview | bash +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} -Options: - --channel <stable|preview> - --stable - --preview - --with-daemon - --without-daemon - -h, --help -EOF +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" } -while [[ $# -gt 0 ]]; do - case "$1" in - --channel) - if [[ $# -lt 2 || -z "${2:-}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - CHANNEL="${2}" - shift 2 - ;; - --channel=*) - CHANNEL="${1#*=}" - if [[ -z "${CHANNEL}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - shift 1 - ;; - --stable) - CHANNEL="stable" - shift 1 - ;; - --preview) - CHANNEL="preview" - shift 1 - ;; - --with-daemon) - WITH_DAEMON="1" - shift 1 - ;; - --without-daemon) - WITH_DAEMON="0" - shift 1 - ;; - -h|--help) - usage - exit 0 - ;; - --) - shift 1 - break - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} -if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then - echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 - exit 1 -fi +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} -if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then - echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 - exit 1 -fi +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +shell_command_cache_hint() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + if [[ "${shell_name}" == "zsh" ]]; then + say " rehash" + else + say " hash -r" + fi +} detect_os() { case "$(uname -s)" in @@ -175,6 +151,387 @@ json_lookup_asset_url() { ' } +resolve_exe_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "happier-server" + else + echo "happier" + fi +} + +resolve_install_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "Happier Server" + else + echo "Happier CLI" + fi +} + +resolve_installed_binary() { + local exe + exe="$(resolve_exe_name)" + local candidate="${INSTALL_DIR}/bin/${exe}" + if [[ -x "${candidate}" ]]; then + printf '%s' "${candidate}" + return 0 + fi + local from_path + from_path="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${from_path}" ]] && [[ -x "${from_path}" ]]; then + printf '%s' "${from_path}" + return 0 + fi + return 1 +} + +action_check() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local ok="1" + local binary_path="${INSTALL_DIR}/bin/${exe}" + local shim_path="${BIN_DIR}/${exe}" + + info "${name} check" + say "- product: ${PRODUCT}" + say "- binary: ${binary_path}" + say "- shim: ${shim_path}" + + if [[ ! -x "${binary_path}" ]]; then + warn "Missing binary: ${binary_path}" + ok="0" + fi + + if [[ ! -e "${shim_path}" ]]; then + warn "Missing shim: ${shim_path}" + fi + + local resolved="" + resolved="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${resolved}" ]]; then + say "- command: ${resolved}" + else + warn "Command not found on PATH: ${exe}" + fi + + local resolved_binary="" + resolved_binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${resolved_binary}" ]]; then + local version_out="" + version_out="$("${resolved_binary}" --version 2>/dev/null || true)" + if [[ -n "${version_out}" ]]; then + say "- version: ${version_out}" + else + warn "Failed to execute: ${resolved_binary}" + ok="0" + fi + fi + + if command -v file >/dev/null 2>&1 && [[ -x "${binary_path}" ]]; then + say + say "file:" + file "${binary_path}" || true + fi + if command -v xattr >/dev/null 2>&1 && [[ -e "${binary_path}" ]]; then + say + say "xattr:" + xattr -l "${binary_path}" 2>/dev/null || true + fi + + say + say "Shell tip (if PATH changed in this session):" + shell_command_cache_hint + + if [[ "${ok}" == "1" ]]; then + success "OK" + return 0 + fi + warn "${name} is not installed correctly." + return 1 +} + +action_restart() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -z "${binary}" ]]; then + warn "${name} is not installed." + return 1 + fi + if [[ "${PRODUCT}" != "cli" ]]; then + warn "Restart is only supported for the CLI daemon." + return 1 + fi + + info "Restarting daemon service (best-effort)..." + if ! "${binary}" daemon service restart >/dev/null 2>&1; then + warn "Daemon service restart failed (it may not be installed)." + warn "Try: ${binary} daemon service install" + return 1 + fi + success "Daemon service restarted." + return 0 +} + +action_uninstall() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${binary}" && "${PRODUCT}" == "cli" ]]; then + "${binary}" daemon service uninstall >/dev/null 2>&1 || true + fi + + rm -f "${BIN_DIR}/${exe}" "${INSTALL_DIR}/bin/${exe}.new" "${INSTALL_DIR}/bin/${exe}.previous" || true + rm -f "${INSTALL_DIR}/bin/${exe}" || true + if [[ "${PURGE_INSTALL_DIR}" == "1" ]]; then + rm -rf "${INSTALL_DIR}" || true + fi + + success "${name} uninstalled." + say "Tip: if your shell still can't find changes, run:" + shell_command_cache_hint + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + +action_version() { + local name + name="$(resolve_install_name)" + + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local os="" + local arch="" + os="$(detect_os)" + arch="$(detect_arch)" + if [[ "${os}" == "unsupported" || "${arch}" == "unsupported" ]]; then + echo "Unsupported platform: $(uname -s)/$(uname -m)" >&2 + return 1 + fi + + local tag="cli-stable" + local asset_regex="^happier-v.*-${os}-${arch}[.]tar[.]gz$" + local version_prefix="happier-v" + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-stable" + asset_regex="^happier-server-v.*-${os}-${arch}[.]tar[.]gz$" + version_prefix="happier-server-v" + fi + if [[ "${CHANNEL}" == "preview" ]]; then + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-preview" + else + tag="cli-preview" + fi + fi + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + curl_auth() { + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + curl -fsSL \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + return + fi + curl -fsSL "$@" + } + + local release_json="" + if ! release_json="$(curl_auth "${api_url}")"; then + echo "Failed to fetch release metadata for ${name}." >&2 + return 1 + fi + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${OS}-${ARCH} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#${version_prefix}}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "${name} installer version check" + say "- channel: ${CHANNEL}" + say "- product: ${PRODUCT}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +usage() { + cat <<'EOF' +Usage: + curl -fsSL https://happier.dev/install | bash + +Preview channel: + curl -fsSL https://happier.dev/install | bash -s -- --channel preview + curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash + curl -fsSL https://happier.dev/install-preview | bash + +Options: + --channel <stable|preview> + --stable + --preview + --with-daemon + --without-daemon + --check + --version + --reinstall + --restart + --uninstall [--purge] + --reset + --debug + -h, --help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --channel) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + CHANNEL="${2}" + shift 2 + ;; + --channel=*) + CHANNEL="${1#*=}" + if [[ -z "${CHANNEL}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + shift 1 + ;; + --stable) + CHANNEL="stable" + shift 1 + ;; + --preview) + CHANNEL="preview" + shift 1 + ;; + --with-daemon) + WITH_DAEMON="1" + shift 1 + ;; + --without-daemon) + WITH_DAEMON="0" + shift 1 + ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --purge) + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift 1 + break + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + +if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then + echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 + exit 1 +fi + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi + +if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -225,7 +582,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else # Prefer built-in macOS tooling to avoid requiring unzip. if command -v ditto >/dev/null 2>&1; then @@ -292,16 +649,58 @@ append_path_hint() { fi local shell_name shell_name="$(basename "${SHELL:-}")" - local rc_file + local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" + local rc_files=() case "${shell_name}" in - zsh) rc_file="$HOME/.zshrc" ;; - bash) rc_file="$HOME/.bashrc" ;; - *) rc_file="$HOME/.profile" ;; + zsh) + rc_files+=("$HOME/.zshrc") + rc_files+=("$HOME/.zprofile") + ;; + bash) + rc_files+=("$HOME/.bashrc") + if [[ -f "$HOME/.bash_profile" ]]; then + rc_files+=("$HOME/.bash_profile") + else + rc_files+=("$HOME/.profile") + fi + ;; + *) + rc_files+=("$HOME/.profile") + ;; esac - local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" - if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then - printf '\n%s\n' "${export_line}" >> "${rc_file}" - echo "Added ${BIN_DIR} to PATH in ${rc_file}" + + local updated=0 + for rc_file in "${rc_files[@]}"; do + if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then + printf '\n%s\n' "${export_line}" >> "${rc_file}" + info "Added ${BIN_DIR} to PATH in ${rc_file}" + updated=1 + fi + done + + if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then + echo + say "${COLOR_BOLD}Next steps${COLOR_RESET}" + say "To use ${EXE_NAME} in your current shell:" + say " export PATH=\"${BIN_DIR}:\$PATH\"" + if [[ "${shell_name}" == "bash" ]]; then + say " source \"$HOME/.bashrc\"" + if [[ -f "$HOME/.bash_profile" ]]; then + say " source \"$HOME/.bash_profile\"" + else + say " source \"$HOME/.profile\"" + fi + elif [[ "${shell_name}" == "zsh" ]]; then + say " source \"$HOME/.zshrc\"" + else + say " source \"$HOME/.profile\"" + fi + say "If your shell still can't find ${EXE_NAME}, run:" + shell_command_cache_hint + say "Or open a new terminal." + elif [[ "${updated}" == "1" ]]; then + echo + say "PATH is already configured in this shell." fi } @@ -350,7 +749,7 @@ if [[ "${CHANNEL}" == "preview" ]]; then fi API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." curl_auth() { if [[ -n "${GITHUB_TOKEN:-}" ]]; then curl -fsSL \ @@ -396,6 +795,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -415,7 +817,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -428,11 +830,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl_auth -o "${SIG_PATH}" "${SIG_URL}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name "${EXE_NAME}" -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then @@ -441,15 +843,25 @@ if [[ -z "${BINARY_PATH}" ]]; then fi mkdir -p "${INSTALL_DIR}/bin" "${BIN_DIR}" -cp "${BINARY_PATH}" "${INSTALL_DIR}/bin/${EXE_NAME}" -chmod +x "${INSTALL_DIR}/bin/${EXE_NAME}" +TARGET_BIN="${INSTALL_DIR}/bin/${EXE_NAME}" +STAGED_BIN="${TARGET_BIN}.new" +PREVIOUS_BIN="${TARGET_BIN}.previous" +cp "${BINARY_PATH}" "${STAGED_BIN}" +chmod +x "${STAGED_BIN}" +if [[ -f "${TARGET_BIN}" ]]; then + cp "${TARGET_BIN}" "${PREVIOUS_BIN}" >/dev/null 2>&1 || true + chmod +x "${PREVIOUS_BIN}" >/dev/null 2>&1 || true +fi +# Avoid ETXTBSY when replacing a running executable: swap the directory entry atomically. +mv -f "${STAGED_BIN}" "${TARGET_BIN}" +chmod +x "${TARGET_BIN}" ln -sf "${INSTALL_DIR}/bin/${EXE_NAME}" "${BIN_DIR}/${EXE_NAME}" append_path_hint if [[ "${PRODUCT}" == "cli" && "${WITH_DAEMON}" == "1" ]]; then echo - echo "Installing daemon service (user-mode)..." + info "Installing daemon service (user-mode)..." if ! "${INSTALL_DIR}/bin/${EXE_NAME}" daemon service install >/dev/null 2>&1; then echo "Warning: daemon service install failed. You can retry manually:" >&2 echo " ${INSTALL_DIR}/bin/${EXE_NAME} daemon service install" >&2 diff --git a/apps/website/public/install.sh b/apps/website/public/install.sh index 29ced9f28..0ac14bd40 100644 --- a/apps/website/public/install.sh +++ b/apps/website/public/install.sh @@ -8,6 +8,9 @@ BIN_DIR="${HAPPIER_BIN_DIR:-$HOME/.local/bin}" WITH_DAEMON="${HAPPIER_WITH_DAEMON:-1}" NO_PATH_UPDATE="${HAPPIER_NO_PATH_UPDATE:-0}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_INSTALL_DIR="${HAPPIER_INSTALLER_PURGE:-0}" GITHUB_REPO="${HAPPIER_GITHUB_REPO:-happier-dev/happier}" GITHUB_TOKEN="${HAPPIER_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}" DEFAULT_MINISIGN_PUBKEY="$(cat <<'EOF' @@ -19,87 +22,60 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" -usage() { - cat <<'EOF' -Usage: - curl -fsSL https://happier.dev/install | bash +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never -Preview channel: - curl -fsSL https://happier.dev/install | bash -s -- --channel preview - curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash - curl -fsSL https://happier.dev/install-preview | bash +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} -Options: - --channel <stable|preview> - --stable - --preview - --with-daemon - --without-daemon - -h, --help -EOF +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" } -while [[ $# -gt 0 ]]; do - case "$1" in - --channel) - if [[ $# -lt 2 || -z "${2:-}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - CHANNEL="${2}" - shift 2 - ;; - --channel=*) - CHANNEL="${1#*=}" - if [[ -z "${CHANNEL}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - shift 1 - ;; - --stable) - CHANNEL="stable" - shift 1 - ;; - --preview) - CHANNEL="preview" - shift 1 - ;; - --with-daemon) - WITH_DAEMON="1" - shift 1 - ;; - --without-daemon) - WITH_DAEMON="0" - shift 1 - ;; - -h|--help) - usage - exit 0 - ;; - --) - shift 1 - break - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} -if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then - echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 - exit 1 -fi +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} -if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then - echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 - exit 1 -fi +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +shell_command_cache_hint() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + if [[ "${shell_name}" == "zsh" ]]; then + say " rehash" + else + say " hash -r" + fi +} detect_os() { case "$(uname -s)" in @@ -175,6 +151,387 @@ json_lookup_asset_url() { ' } +resolve_exe_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "happier-server" + else + echo "happier" + fi +} + +resolve_install_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "Happier Server" + else + echo "Happier CLI" + fi +} + +resolve_installed_binary() { + local exe + exe="$(resolve_exe_name)" + local candidate="${INSTALL_DIR}/bin/${exe}" + if [[ -x "${candidate}" ]]; then + printf '%s' "${candidate}" + return 0 + fi + local from_path + from_path="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${from_path}" ]] && [[ -x "${from_path}" ]]; then + printf '%s' "${from_path}" + return 0 + fi + return 1 +} + +action_check() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local ok="1" + local binary_path="${INSTALL_DIR}/bin/${exe}" + local shim_path="${BIN_DIR}/${exe}" + + info "${name} check" + say "- product: ${PRODUCT}" + say "- binary: ${binary_path}" + say "- shim: ${shim_path}" + + if [[ ! -x "${binary_path}" ]]; then + warn "Missing binary: ${binary_path}" + ok="0" + fi + + if [[ ! -e "${shim_path}" ]]; then + warn "Missing shim: ${shim_path}" + fi + + local resolved="" + resolved="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${resolved}" ]]; then + say "- command: ${resolved}" + else + warn "Command not found on PATH: ${exe}" + fi + + local resolved_binary="" + resolved_binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${resolved_binary}" ]]; then + local version_out="" + version_out="$("${resolved_binary}" --version 2>/dev/null || true)" + if [[ -n "${version_out}" ]]; then + say "- version: ${version_out}" + else + warn "Failed to execute: ${resolved_binary}" + ok="0" + fi + fi + + if command -v file >/dev/null 2>&1 && [[ -x "${binary_path}" ]]; then + say + say "file:" + file "${binary_path}" || true + fi + if command -v xattr >/dev/null 2>&1 && [[ -e "${binary_path}" ]]; then + say + say "xattr:" + xattr -l "${binary_path}" 2>/dev/null || true + fi + + say + say "Shell tip (if PATH changed in this session):" + shell_command_cache_hint + + if [[ "${ok}" == "1" ]]; then + success "OK" + return 0 + fi + warn "${name} is not installed correctly." + return 1 +} + +action_restart() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -z "${binary}" ]]; then + warn "${name} is not installed." + return 1 + fi + if [[ "${PRODUCT}" != "cli" ]]; then + warn "Restart is only supported for the CLI daemon." + return 1 + fi + + info "Restarting daemon service (best-effort)..." + if ! "${binary}" daemon service restart >/dev/null 2>&1; then + warn "Daemon service restart failed (it may not be installed)." + warn "Try: ${binary} daemon service install" + return 1 + fi + success "Daemon service restarted." + return 0 +} + +action_uninstall() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${binary}" && "${PRODUCT}" == "cli" ]]; then + "${binary}" daemon service uninstall >/dev/null 2>&1 || true + fi + + rm -f "${BIN_DIR}/${exe}" "${INSTALL_DIR}/bin/${exe}.new" "${INSTALL_DIR}/bin/${exe}.previous" || true + rm -f "${INSTALL_DIR}/bin/${exe}" || true + if [[ "${PURGE_INSTALL_DIR}" == "1" ]]; then + rm -rf "${INSTALL_DIR}" || true + fi + + success "${name} uninstalled." + say "Tip: if your shell still can't find changes, run:" + shell_command_cache_hint + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + +action_version() { + local name + name="$(resolve_install_name)" + + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local os="" + local arch="" + os="$(detect_os)" + arch="$(detect_arch)" + if [[ "${os}" == "unsupported" || "${arch}" == "unsupported" ]]; then + echo "Unsupported platform: $(uname -s)/$(uname -m)" >&2 + return 1 + fi + + local tag="cli-stable" + local asset_regex="^happier-v.*-${os}-${arch}[.]tar[.]gz$" + local version_prefix="happier-v" + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-stable" + asset_regex="^happier-server-v.*-${os}-${arch}[.]tar[.]gz$" + version_prefix="happier-server-v" + fi + if [[ "${CHANNEL}" == "preview" ]]; then + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-preview" + else + tag="cli-preview" + fi + fi + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + curl_auth() { + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + curl -fsSL \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + return + fi + curl -fsSL "$@" + } + + local release_json="" + if ! release_json="$(curl_auth "${api_url}")"; then + echo "Failed to fetch release metadata for ${name}." >&2 + return 1 + fi + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${OS}-${ARCH} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#${version_prefix}}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "${name} installer version check" + say "- channel: ${CHANNEL}" + say "- product: ${PRODUCT}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +usage() { + cat <<'EOF' +Usage: + curl -fsSL https://happier.dev/install | bash + +Preview channel: + curl -fsSL https://happier.dev/install | bash -s -- --channel preview + curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash + curl -fsSL https://happier.dev/install-preview | bash + +Options: + --channel <stable|preview> + --stable + --preview + --with-daemon + --without-daemon + --check + --version + --reinstall + --restart + --uninstall [--purge] + --reset + --debug + -h, --help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --channel) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + CHANNEL="${2}" + shift 2 + ;; + --channel=*) + CHANNEL="${1#*=}" + if [[ -z "${CHANNEL}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + shift 1 + ;; + --stable) + CHANNEL="stable" + shift 1 + ;; + --preview) + CHANNEL="preview" + shift 1 + ;; + --with-daemon) + WITH_DAEMON="1" + shift 1 + ;; + --without-daemon) + WITH_DAEMON="0" + shift 1 + ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --purge) + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift 1 + break + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + +if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then + echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 + exit 1 +fi + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi + +if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -225,7 +582,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else # Prefer built-in macOS tooling to avoid requiring unzip. if command -v ditto >/dev/null 2>&1; then @@ -292,16 +649,58 @@ append_path_hint() { fi local shell_name shell_name="$(basename "${SHELL:-}")" - local rc_file + local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" + local rc_files=() case "${shell_name}" in - zsh) rc_file="$HOME/.zshrc" ;; - bash) rc_file="$HOME/.bashrc" ;; - *) rc_file="$HOME/.profile" ;; + zsh) + rc_files+=("$HOME/.zshrc") + rc_files+=("$HOME/.zprofile") + ;; + bash) + rc_files+=("$HOME/.bashrc") + if [[ -f "$HOME/.bash_profile" ]]; then + rc_files+=("$HOME/.bash_profile") + else + rc_files+=("$HOME/.profile") + fi + ;; + *) + rc_files+=("$HOME/.profile") + ;; esac - local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" - if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then - printf '\n%s\n' "${export_line}" >> "${rc_file}" - echo "Added ${BIN_DIR} to PATH in ${rc_file}" + + local updated=0 + for rc_file in "${rc_files[@]}"; do + if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then + printf '\n%s\n' "${export_line}" >> "${rc_file}" + info "Added ${BIN_DIR} to PATH in ${rc_file}" + updated=1 + fi + done + + if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then + echo + say "${COLOR_BOLD}Next steps${COLOR_RESET}" + say "To use ${EXE_NAME} in your current shell:" + say " export PATH=\"${BIN_DIR}:\$PATH\"" + if [[ "${shell_name}" == "bash" ]]; then + say " source \"$HOME/.bashrc\"" + if [[ -f "$HOME/.bash_profile" ]]; then + say " source \"$HOME/.bash_profile\"" + else + say " source \"$HOME/.profile\"" + fi + elif [[ "${shell_name}" == "zsh" ]]; then + say " source \"$HOME/.zshrc\"" + else + say " source \"$HOME/.profile\"" + fi + say "If your shell still can't find ${EXE_NAME}, run:" + shell_command_cache_hint + say "Or open a new terminal." + elif [[ "${updated}" == "1" ]]; then + echo + say "PATH is already configured in this shell." fi } @@ -350,7 +749,7 @@ if [[ "${CHANNEL}" == "preview" ]]; then fi API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." curl_auth() { if [[ -n "${GITHUB_TOKEN:-}" ]]; then curl -fsSL \ @@ -396,6 +795,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -415,7 +817,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -428,11 +830,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl_auth -o "${SIG_PATH}" "${SIG_URL}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name "${EXE_NAME}" -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then @@ -441,15 +843,25 @@ if [[ -z "${BINARY_PATH}" ]]; then fi mkdir -p "${INSTALL_DIR}/bin" "${BIN_DIR}" -cp "${BINARY_PATH}" "${INSTALL_DIR}/bin/${EXE_NAME}" -chmod +x "${INSTALL_DIR}/bin/${EXE_NAME}" +TARGET_BIN="${INSTALL_DIR}/bin/${EXE_NAME}" +STAGED_BIN="${TARGET_BIN}.new" +PREVIOUS_BIN="${TARGET_BIN}.previous" +cp "${BINARY_PATH}" "${STAGED_BIN}" +chmod +x "${STAGED_BIN}" +if [[ -f "${TARGET_BIN}" ]]; then + cp "${TARGET_BIN}" "${PREVIOUS_BIN}" >/dev/null 2>&1 || true + chmod +x "${PREVIOUS_BIN}" >/dev/null 2>&1 || true +fi +# Avoid ETXTBSY when replacing a running executable: swap the directory entry atomically. +mv -f "${STAGED_BIN}" "${TARGET_BIN}" +chmod +x "${TARGET_BIN}" ln -sf "${INSTALL_DIR}/bin/${EXE_NAME}" "${BIN_DIR}/${EXE_NAME}" append_path_hint if [[ "${PRODUCT}" == "cli" && "${WITH_DAEMON}" == "1" ]]; then echo - echo "Installing daemon service (user-mode)..." + info "Installing daemon service (user-mode)..." if ! "${INSTALL_DIR}/bin/${EXE_NAME}" daemon service install >/dev/null 2>&1; then echo "Warning: daemon service install failed. You can retry manually:" >&2 echo " ${INSTALL_DIR}/bin/${EXE_NAME} daemon service install" >&2 diff --git a/apps/website/public/self-host b/apps/website/public/self-host index bf4666e55..8838e036f 100644 --- a/apps/website/public/self-host +++ b/apps/website/public/self-host @@ -9,6 +9,9 @@ if [[ -n "${HAPPIER_SELF_HOST_MODE:-}" ]]; then fi WITH_CLI="${HAPPIER_WITH_CLI:-1}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_DATA="${HAPPIER_SELF_HOST_PURGE_DATA:-0}" HAPPIER_HOME="${HAPPIER_HOME:-${HOME}/.happier}" STACK_INSTALL_DIR="${HAPPIER_STACK_INSTALL_ROOT:-}" STACK_BIN_DIR="${HAPPIER_STACK_BIN_DIR:-}" @@ -22,6 +25,187 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never + +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} + +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" +} + +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} + +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} + +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +json_lookup_asset_url() { + local json="$1" + local name_regex="$2" + # GitHub API JSON is typically pretty-printed (newlines + spaces). Avoid "minifying" into one + # giant line (which can overflow awk line-length limits on some platforms) and instead parse + # line-by-line within the assets array. We intentionally return the *last* match to support + # rolling tags that may contain multiple versions: newest assets are appended later in the JSON. + printf '%s' "$json" | awk -v re="$name_regex" ' + BEGIN { + in_assets = 0 + name = "" + last = "" + } + { + raw = $0 + if (in_assets == 0) { + if (raw ~ /"assets"[[:space:]]*:[[:space:]]*\[/) { + in_assets = 1 + } + next + } + + # End of the assets array. The GitHub API pretty-prints `],` on its own line. + if (raw ~ /^[[:space:]]*][[:space:]]*,?[[:space:]]*$/) { + in_assets = 0 + next + } + + if (raw ~ /"name"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"name"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + if (q > 0) { + name = substr(v, 1, q - 1) + } + } + + if (raw ~ /"browser_download_url"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"browser_download_url"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + url = "" + if (q > 0) { + url = substr(v, 1, q - 1) + } + if (name ~ re && url != "") { + last = url + } + } + } + END { + if (last != "") { + print last + } + } + ' +} + +action_version() { + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local tag="stack-stable" + if [[ "${CHANNEL}" == "preview" ]]; then + tag="stack-preview" + fi + + local uname_os="" + uname_os="$(uname -s)" + local os="" + case "${uname_os}" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) + echo "Unsupported platform: ${uname_os}" >&2 + return 1 + ;; + esac + + local arch_raw="" + arch_raw="$(uname -m)" + local arch="" + case "${arch_raw}" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) + echo "Unsupported architecture: ${arch_raw}" >&2 + return 1 + ;; + esac + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + local release_json="" + if ! release_json="$(curl -fsSL "${api_url}")"; then + echo "Failed to fetch release metadata for Happier Stack." >&2 + return 1 + fi + + local asset_regex="^hstack-v.*-${os}-${arch}[.]tar[.]gz$" + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${os}-${arch} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#hstack-v}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "Happier Stack installer version check" + say "- channel: ${CHANNEL}" + say "- mode: ${MODE}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + usage() { cat <<'EOF' Usage: @@ -46,6 +230,14 @@ Options: --channel <stable|preview> --stable --preview + --check + --version + --reinstall + --restart + --uninstall [--purge-data] + --reset + --purge-data + --debug -h, --help EOF } @@ -117,6 +309,39 @@ while [[ $# -gt 0 ]]; do CHANNEL="preview" shift 1 ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_DATA="1" + shift 1 + ;; + --purge-data) + PURGE_DATA="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; --) shift 1 break @@ -129,6 +354,10 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + if [[ "${MODE}" != "user" && "${MODE}" != "system" ]]; then echo "Invalid mode: ${MODE}. Expected user or system." >&2 exit 1 @@ -139,6 +368,11 @@ if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then exit 1 fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi + UNAME="$(uname -s)" OS="" case "${UNAME}" in @@ -182,11 +416,6 @@ if [[ "${MODE}" == "system" && "${EUID}" -ne 0 ]]; then exit 1 fi -if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then - echo "systemctl is required for self-host installation on Linux." >&2 - exit 1 -fi - ARCH="$(uname -m)" case "${ARCH}" in x86_64|amd64) ARCH="x64" ;; @@ -202,52 +431,106 @@ if [[ "${CHANNEL}" == "preview" ]]; then TAG="stack-preview" fi -json_lookup_asset_url() { - local json="$1" - local name_regex="$2" - # GitHub API JSON is typically pretty-printed (newlines + spaces). Minify and then parse using a - # tiny jq-free state machine that pairs `"name":"..."` with the next `"browser_download_url":"..."`. - # We intentionally return the *last* match to support rolling tags that may contain multiple - # versions: newest assets are appended later in the release JSON. - printf '%s' "$json" | tr -d '[:space:]' | awk -v re="$name_regex" ' - { - s = $0 - assets_key = "\"assets\":[" - a = index(s, assets_key) - if (a > 0) { - s = substr(s, a + length(assets_key)) - } - name_key = "\"name\":\"" - url_key = "\"browser_download_url\":\"" - last = "" - while (1) { - p = index(s, name_key) - if (p == 0) break - s = substr(s, p + length(name_key)) - q = index(s, "\"") - if (q == 0) break - name = substr(s, 1, q - 1) - s = substr(s, q + 1) - - u = index(s, url_key) - if (u == 0) continue - s = substr(s, u + length(url_key)) - v = index(s, "\"") - if (v == 0) break - url = substr(s, 1, v - 1) - s = substr(s, v + 1) +resolve_hstack_path() { + if command -v hstack >/dev/null 2>&1; then + command -v hstack + return 0 + fi + if [[ -x "${STACK_INSTALL_DIR}/bin/hstack" ]]; then + echo "${STACK_INSTALL_DIR}/bin/hstack" + return 0 + fi + if [[ -x "${STACK_BIN_DIR}/hstack" ]]; then + echo "${STACK_BIN_DIR}/hstack" + return 0 + fi + return 1 +} - if (name ~ re && url != "") { - last = url - } - } - if (last != "") { - print last - } - } - ' +print_self_host_log_guidance() { + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi } +action_check() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + warn "Run: curl -fsSL https://happier.dev/self-host-preview | bash" + return 1 + fi + info "Checking Happier Self-Host..." + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + "${hstack}" self-host doctor --mode="${MODE}" --channel="${CHANNEL}" + return $? +} + +action_uninstall() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + return 1 + fi + local args=(self-host uninstall --yes --non-interactive --channel="${CHANNEL}" --mode="${MODE}") + if [[ "${PURGE_DATA}" == "1" ]]; then + args+=(--purge-data) + fi + "${hstack}" "${args[@]}" + return $? +} + +action_restart() { + local service="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + if [[ "${OS}" == "linux" ]] && command -v systemctl >/dev/null 2>&1; then + info "Restarting ${service}..." + if [[ "${MODE}" == "system" ]]; then + systemctl restart "${service}.service" + else + systemctl --user restart "${service}.service" + fi + fi + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -n "${hstack}" ]]; then + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + fi + return 0 +} + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi + +if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl is required for self-host installation on Linux." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -296,7 +579,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else if command -v unzip >/dev/null 2>&1; then unzip -q "${archive_path}" -d "${extract_dir}" @@ -345,7 +628,7 @@ write_minisign_public_key() { } API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." if ! RELEASE_JSON="$(curl -fsSL "${API_URL}")"; then if [[ "${CHANNEL}" == "stable" ]]; then echo "No stable releases found for Happier Stack." >&2 @@ -384,6 +667,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -393,7 +679,7 @@ CHECKSUMS_PATH="${TMP_DIR}/checksums.txt" curl -fsSL "${ASSET_URL}" -o "${ARCHIVE_PATH}" curl -fsSL "${CHECKSUMS_URL}" -o "${CHECKSUMS_PATH}" -EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1)" +EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1 || true)" if [[ -z "${EXPECTED_SHA}" ]]; then echo "Failed to resolve checksum for $(basename "${ASSET_URL}")" >&2 exit 1 @@ -403,7 +689,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -416,11 +702,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl -fsSL "${SIG_URL}" -o "${SIG_PATH}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name hstack -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then echo "Failed to locate extracted hstack binary." >&2 @@ -432,7 +718,7 @@ cp "${BINARY_PATH}" "${STACK_INSTALL_DIR}/bin/hstack" chmod +x "${STACK_INSTALL_DIR}/bin/hstack" ln -sf "${STACK_INSTALL_DIR}/bin/hstack" "${STACK_BIN_DIR}/hstack" -echo "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" +success "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" SELF_HOST_ARGS=(self-host install --non-interactive --channel="${CHANNEL}" --mode="${MODE}") if [[ "${WITH_CLI}" != "1" ]]; then @@ -442,10 +728,56 @@ fi export HAPPIER_NONINTERACTIVE="${NONINTERACTIVE}" if [[ "${NONINTERACTIVE}" != "1" ]]; then - echo "Starting Happier Self-Host guided installation..." + info "Starting Happier Self-Host guided installation..." + say + info "This can take a few minutes. If it looks stuck, check logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + say +fi +if ! "${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}"; then + warn + warn "[self-host] install failed" + say + info "Troubleshooting:" + say " ${STACK_BIN_DIR}/hstack self-host status --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host doctor --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host config view --mode=${MODE} --channel=${CHANNEL} --json" + say + info "Logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.err.log" + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + exit 1 fi -"${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}" echo -echo "Happier Self-Host installation completed." -echo "Run: ${STACK_BIN_DIR}/hstack self-host status" +success "Happier Self-Host installation completed." +info "Run: ${STACK_BIN_DIR}/hstack self-host status" diff --git a/apps/website/public/self-host-preview b/apps/website/public/self-host-preview index 6c270dad9..00b53f4c0 100644 --- a/apps/website/public/self-host-preview +++ b/apps/website/public/self-host-preview @@ -9,6 +9,9 @@ if [[ -n "${HAPPIER_SELF_HOST_MODE:-}" ]]; then fi WITH_CLI="${HAPPIER_WITH_CLI:-1}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_DATA="${HAPPIER_SELF_HOST_PURGE_DATA:-0}" HAPPIER_HOME="${HAPPIER_HOME:-${HOME}/.happier}" STACK_INSTALL_DIR="${HAPPIER_STACK_INSTALL_ROOT:-}" STACK_BIN_DIR="${HAPPIER_STACK_BIN_DIR:-}" @@ -22,6 +25,187 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never + +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} + +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" +} + +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} + +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} + +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +json_lookup_asset_url() { + local json="$1" + local name_regex="$2" + # GitHub API JSON is typically pretty-printed (newlines + spaces). Avoid "minifying" into one + # giant line (which can overflow awk line-length limits on some platforms) and instead parse + # line-by-line within the assets array. We intentionally return the *last* match to support + # rolling tags that may contain multiple versions: newest assets are appended later in the JSON. + printf '%s' "$json" | awk -v re="$name_regex" ' + BEGIN { + in_assets = 0 + name = "" + last = "" + } + { + raw = $0 + if (in_assets == 0) { + if (raw ~ /"assets"[[:space:]]*:[[:space:]]*\[/) { + in_assets = 1 + } + next + } + + # End of the assets array. The GitHub API pretty-prints `],` on its own line. + if (raw ~ /^[[:space:]]*][[:space:]]*,?[[:space:]]*$/) { + in_assets = 0 + next + } + + if (raw ~ /"name"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"name"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + if (q > 0) { + name = substr(v, 1, q - 1) + } + } + + if (raw ~ /"browser_download_url"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"browser_download_url"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + url = "" + if (q > 0) { + url = substr(v, 1, q - 1) + } + if (name ~ re && url != "") { + last = url + } + } + } + END { + if (last != "") { + print last + } + } + ' +} + +action_version() { + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local tag="stack-stable" + if [[ "${CHANNEL}" == "preview" ]]; then + tag="stack-preview" + fi + + local uname_os="" + uname_os="$(uname -s)" + local os="" + case "${uname_os}" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) + echo "Unsupported platform: ${uname_os}" >&2 + return 1 + ;; + esac + + local arch_raw="" + arch_raw="$(uname -m)" + local arch="" + case "${arch_raw}" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) + echo "Unsupported architecture: ${arch_raw}" >&2 + return 1 + ;; + esac + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + local release_json="" + if ! release_json="$(curl -fsSL "${api_url}")"; then + echo "Failed to fetch release metadata for Happier Stack." >&2 + return 1 + fi + + local asset_regex="^hstack-v.*-${os}-${arch}[.]tar[.]gz$" + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${os}-${arch} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#hstack-v}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "Happier Stack installer version check" + say "- channel: ${CHANNEL}" + say "- mode: ${MODE}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + usage() { cat <<'EOF' Usage: @@ -46,6 +230,14 @@ Options: --channel <stable|preview> --stable --preview + --check + --version + --reinstall + --restart + --uninstall [--purge-data] + --reset + --purge-data + --debug -h, --help EOF } @@ -117,6 +309,39 @@ while [[ $# -gt 0 ]]; do CHANNEL="preview" shift 1 ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_DATA="1" + shift 1 + ;; + --purge-data) + PURGE_DATA="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; --) shift 1 break @@ -129,6 +354,10 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + if [[ "${MODE}" != "user" && "${MODE}" != "system" ]]; then echo "Invalid mode: ${MODE}. Expected user or system." >&2 exit 1 @@ -139,6 +368,11 @@ if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then exit 1 fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi + UNAME="$(uname -s)" OS="" case "${UNAME}" in @@ -182,11 +416,6 @@ if [[ "${MODE}" == "system" && "${EUID}" -ne 0 ]]; then exit 1 fi -if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then - echo "systemctl is required for self-host installation on Linux." >&2 - exit 1 -fi - ARCH="$(uname -m)" case "${ARCH}" in x86_64|amd64) ARCH="x64" ;; @@ -202,52 +431,106 @@ if [[ "${CHANNEL}" == "preview" ]]; then TAG="stack-preview" fi -json_lookup_asset_url() { - local json="$1" - local name_regex="$2" - # GitHub API JSON is typically pretty-printed (newlines + spaces). Minify and then parse using a - # tiny jq-free state machine that pairs `"name":"..."` with the next `"browser_download_url":"..."`. - # We intentionally return the *last* match to support rolling tags that may contain multiple - # versions: newest assets are appended later in the release JSON. - printf '%s' "$json" | tr -d '[:space:]' | awk -v re="$name_regex" ' - { - s = $0 - assets_key = "\"assets\":[" - a = index(s, assets_key) - if (a > 0) { - s = substr(s, a + length(assets_key)) - } - name_key = "\"name\":\"" - url_key = "\"browser_download_url\":\"" - last = "" - while (1) { - p = index(s, name_key) - if (p == 0) break - s = substr(s, p + length(name_key)) - q = index(s, "\"") - if (q == 0) break - name = substr(s, 1, q - 1) - s = substr(s, q + 1) - - u = index(s, url_key) - if (u == 0) continue - s = substr(s, u + length(url_key)) - v = index(s, "\"") - if (v == 0) break - url = substr(s, 1, v - 1) - s = substr(s, v + 1) +resolve_hstack_path() { + if command -v hstack >/dev/null 2>&1; then + command -v hstack + return 0 + fi + if [[ -x "${STACK_INSTALL_DIR}/bin/hstack" ]]; then + echo "${STACK_INSTALL_DIR}/bin/hstack" + return 0 + fi + if [[ -x "${STACK_BIN_DIR}/hstack" ]]; then + echo "${STACK_BIN_DIR}/hstack" + return 0 + fi + return 1 +} - if (name ~ re && url != "") { - last = url - } - } - if (last != "") { - print last - } - } - ' +print_self_host_log_guidance() { + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi } +action_check() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + warn "Run: curl -fsSL https://happier.dev/self-host-preview | bash" + return 1 + fi + info "Checking Happier Self-Host..." + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + "${hstack}" self-host doctor --mode="${MODE}" --channel="${CHANNEL}" + return $? +} + +action_uninstall() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + return 1 + fi + local args=(self-host uninstall --yes --non-interactive --channel="${CHANNEL}" --mode="${MODE}") + if [[ "${PURGE_DATA}" == "1" ]]; then + args+=(--purge-data) + fi + "${hstack}" "${args[@]}" + return $? +} + +action_restart() { + local service="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + if [[ "${OS}" == "linux" ]] && command -v systemctl >/dev/null 2>&1; then + info "Restarting ${service}..." + if [[ "${MODE}" == "system" ]]; then + systemctl restart "${service}.service" + else + systemctl --user restart "${service}.service" + fi + fi + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -n "${hstack}" ]]; then + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + fi + return 0 +} + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi + +if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl is required for self-host installation on Linux." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -296,7 +579,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else if command -v unzip >/dev/null 2>&1; then unzip -q "${archive_path}" -d "${extract_dir}" @@ -345,7 +628,7 @@ write_minisign_public_key() { } API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." if ! RELEASE_JSON="$(curl -fsSL "${API_URL}")"; then if [[ "${CHANNEL}" == "stable" ]]; then echo "No stable releases found for Happier Stack." >&2 @@ -384,6 +667,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -393,7 +679,7 @@ CHECKSUMS_PATH="${TMP_DIR}/checksums.txt" curl -fsSL "${ASSET_URL}" -o "${ARCHIVE_PATH}" curl -fsSL "${CHECKSUMS_URL}" -o "${CHECKSUMS_PATH}" -EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1)" +EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1 || true)" if [[ -z "${EXPECTED_SHA}" ]]; then echo "Failed to resolve checksum for $(basename "${ASSET_URL}")" >&2 exit 1 @@ -403,7 +689,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -416,11 +702,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl -fsSL "${SIG_URL}" -o "${SIG_PATH}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name hstack -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then echo "Failed to locate extracted hstack binary." >&2 @@ -432,7 +718,7 @@ cp "${BINARY_PATH}" "${STACK_INSTALL_DIR}/bin/hstack" chmod +x "${STACK_INSTALL_DIR}/bin/hstack" ln -sf "${STACK_INSTALL_DIR}/bin/hstack" "${STACK_BIN_DIR}/hstack" -echo "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" +success "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" SELF_HOST_ARGS=(self-host install --non-interactive --channel="${CHANNEL}" --mode="${MODE}") if [[ "${WITH_CLI}" != "1" ]]; then @@ -442,10 +728,56 @@ fi export HAPPIER_NONINTERACTIVE="${NONINTERACTIVE}" if [[ "${NONINTERACTIVE}" != "1" ]]; then - echo "Starting Happier Self-Host guided installation..." + info "Starting Happier Self-Host guided installation..." + say + info "This can take a few minutes. If it looks stuck, check logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + say +fi +if ! "${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}"; then + warn + warn "[self-host] install failed" + say + info "Troubleshooting:" + say " ${STACK_BIN_DIR}/hstack self-host status --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host doctor --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host config view --mode=${MODE} --channel=${CHANNEL} --json" + say + info "Logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.err.log" + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + exit 1 fi -"${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}" echo -echo "Happier Self-Host installation completed." -echo "Run: ${STACK_BIN_DIR}/hstack self-host status" +success "Happier Self-Host installation completed." +info "Run: ${STACK_BIN_DIR}/hstack self-host status" diff --git a/apps/website/public/self-host-preview.sh b/apps/website/public/self-host-preview.sh index 6c270dad9..00b53f4c0 100644 --- a/apps/website/public/self-host-preview.sh +++ b/apps/website/public/self-host-preview.sh @@ -9,6 +9,9 @@ if [[ -n "${HAPPIER_SELF_HOST_MODE:-}" ]]; then fi WITH_CLI="${HAPPIER_WITH_CLI:-1}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_DATA="${HAPPIER_SELF_HOST_PURGE_DATA:-0}" HAPPIER_HOME="${HAPPIER_HOME:-${HOME}/.happier}" STACK_INSTALL_DIR="${HAPPIER_STACK_INSTALL_ROOT:-}" STACK_BIN_DIR="${HAPPIER_STACK_BIN_DIR:-}" @@ -22,6 +25,187 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never + +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} + +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" +} + +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} + +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} + +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +json_lookup_asset_url() { + local json="$1" + local name_regex="$2" + # GitHub API JSON is typically pretty-printed (newlines + spaces). Avoid "minifying" into one + # giant line (which can overflow awk line-length limits on some platforms) and instead parse + # line-by-line within the assets array. We intentionally return the *last* match to support + # rolling tags that may contain multiple versions: newest assets are appended later in the JSON. + printf '%s' "$json" | awk -v re="$name_regex" ' + BEGIN { + in_assets = 0 + name = "" + last = "" + } + { + raw = $0 + if (in_assets == 0) { + if (raw ~ /"assets"[[:space:]]*:[[:space:]]*\[/) { + in_assets = 1 + } + next + } + + # End of the assets array. The GitHub API pretty-prints `],` on its own line. + if (raw ~ /^[[:space:]]*][[:space:]]*,?[[:space:]]*$/) { + in_assets = 0 + next + } + + if (raw ~ /"name"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"name"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + if (q > 0) { + name = substr(v, 1, q - 1) + } + } + + if (raw ~ /"browser_download_url"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"browser_download_url"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + url = "" + if (q > 0) { + url = substr(v, 1, q - 1) + } + if (name ~ re && url != "") { + last = url + } + } + } + END { + if (last != "") { + print last + } + } + ' +} + +action_version() { + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local tag="stack-stable" + if [[ "${CHANNEL}" == "preview" ]]; then + tag="stack-preview" + fi + + local uname_os="" + uname_os="$(uname -s)" + local os="" + case "${uname_os}" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) + echo "Unsupported platform: ${uname_os}" >&2 + return 1 + ;; + esac + + local arch_raw="" + arch_raw="$(uname -m)" + local arch="" + case "${arch_raw}" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) + echo "Unsupported architecture: ${arch_raw}" >&2 + return 1 + ;; + esac + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + local release_json="" + if ! release_json="$(curl -fsSL "${api_url}")"; then + echo "Failed to fetch release metadata for Happier Stack." >&2 + return 1 + fi + + local asset_regex="^hstack-v.*-${os}-${arch}[.]tar[.]gz$" + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${os}-${arch} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#hstack-v}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "Happier Stack installer version check" + say "- channel: ${CHANNEL}" + say "- mode: ${MODE}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + usage() { cat <<'EOF' Usage: @@ -46,6 +230,14 @@ Options: --channel <stable|preview> --stable --preview + --check + --version + --reinstall + --restart + --uninstall [--purge-data] + --reset + --purge-data + --debug -h, --help EOF } @@ -117,6 +309,39 @@ while [[ $# -gt 0 ]]; do CHANNEL="preview" shift 1 ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_DATA="1" + shift 1 + ;; + --purge-data) + PURGE_DATA="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; --) shift 1 break @@ -129,6 +354,10 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + if [[ "${MODE}" != "user" && "${MODE}" != "system" ]]; then echo "Invalid mode: ${MODE}. Expected user or system." >&2 exit 1 @@ -139,6 +368,11 @@ if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then exit 1 fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi + UNAME="$(uname -s)" OS="" case "${UNAME}" in @@ -182,11 +416,6 @@ if [[ "${MODE}" == "system" && "${EUID}" -ne 0 ]]; then exit 1 fi -if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then - echo "systemctl is required for self-host installation on Linux." >&2 - exit 1 -fi - ARCH="$(uname -m)" case "${ARCH}" in x86_64|amd64) ARCH="x64" ;; @@ -202,52 +431,106 @@ if [[ "${CHANNEL}" == "preview" ]]; then TAG="stack-preview" fi -json_lookup_asset_url() { - local json="$1" - local name_regex="$2" - # GitHub API JSON is typically pretty-printed (newlines + spaces). Minify and then parse using a - # tiny jq-free state machine that pairs `"name":"..."` with the next `"browser_download_url":"..."`. - # We intentionally return the *last* match to support rolling tags that may contain multiple - # versions: newest assets are appended later in the release JSON. - printf '%s' "$json" | tr -d '[:space:]' | awk -v re="$name_regex" ' - { - s = $0 - assets_key = "\"assets\":[" - a = index(s, assets_key) - if (a > 0) { - s = substr(s, a + length(assets_key)) - } - name_key = "\"name\":\"" - url_key = "\"browser_download_url\":\"" - last = "" - while (1) { - p = index(s, name_key) - if (p == 0) break - s = substr(s, p + length(name_key)) - q = index(s, "\"") - if (q == 0) break - name = substr(s, 1, q - 1) - s = substr(s, q + 1) - - u = index(s, url_key) - if (u == 0) continue - s = substr(s, u + length(url_key)) - v = index(s, "\"") - if (v == 0) break - url = substr(s, 1, v - 1) - s = substr(s, v + 1) +resolve_hstack_path() { + if command -v hstack >/dev/null 2>&1; then + command -v hstack + return 0 + fi + if [[ -x "${STACK_INSTALL_DIR}/bin/hstack" ]]; then + echo "${STACK_INSTALL_DIR}/bin/hstack" + return 0 + fi + if [[ -x "${STACK_BIN_DIR}/hstack" ]]; then + echo "${STACK_BIN_DIR}/hstack" + return 0 + fi + return 1 +} - if (name ~ re && url != "") { - last = url - } - } - if (last != "") { - print last - } - } - ' +print_self_host_log_guidance() { + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi } +action_check() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + warn "Run: curl -fsSL https://happier.dev/self-host-preview | bash" + return 1 + fi + info "Checking Happier Self-Host..." + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + "${hstack}" self-host doctor --mode="${MODE}" --channel="${CHANNEL}" + return $? +} + +action_uninstall() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + return 1 + fi + local args=(self-host uninstall --yes --non-interactive --channel="${CHANNEL}" --mode="${MODE}") + if [[ "${PURGE_DATA}" == "1" ]]; then + args+=(--purge-data) + fi + "${hstack}" "${args[@]}" + return $? +} + +action_restart() { + local service="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + if [[ "${OS}" == "linux" ]] && command -v systemctl >/dev/null 2>&1; then + info "Restarting ${service}..." + if [[ "${MODE}" == "system" ]]; then + systemctl restart "${service}.service" + else + systemctl --user restart "${service}.service" + fi + fi + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -n "${hstack}" ]]; then + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + fi + return 0 +} + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi + +if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl is required for self-host installation on Linux." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -296,7 +579,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else if command -v unzip >/dev/null 2>&1; then unzip -q "${archive_path}" -d "${extract_dir}" @@ -345,7 +628,7 @@ write_minisign_public_key() { } API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." if ! RELEASE_JSON="$(curl -fsSL "${API_URL}")"; then if [[ "${CHANNEL}" == "stable" ]]; then echo "No stable releases found for Happier Stack." >&2 @@ -384,6 +667,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -393,7 +679,7 @@ CHECKSUMS_PATH="${TMP_DIR}/checksums.txt" curl -fsSL "${ASSET_URL}" -o "${ARCHIVE_PATH}" curl -fsSL "${CHECKSUMS_URL}" -o "${CHECKSUMS_PATH}" -EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1)" +EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1 || true)" if [[ -z "${EXPECTED_SHA}" ]]; then echo "Failed to resolve checksum for $(basename "${ASSET_URL}")" >&2 exit 1 @@ -403,7 +689,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -416,11 +702,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl -fsSL "${SIG_URL}" -o "${SIG_PATH}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name hstack -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then echo "Failed to locate extracted hstack binary." >&2 @@ -432,7 +718,7 @@ cp "${BINARY_PATH}" "${STACK_INSTALL_DIR}/bin/hstack" chmod +x "${STACK_INSTALL_DIR}/bin/hstack" ln -sf "${STACK_INSTALL_DIR}/bin/hstack" "${STACK_BIN_DIR}/hstack" -echo "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" +success "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" SELF_HOST_ARGS=(self-host install --non-interactive --channel="${CHANNEL}" --mode="${MODE}") if [[ "${WITH_CLI}" != "1" ]]; then @@ -442,10 +728,56 @@ fi export HAPPIER_NONINTERACTIVE="${NONINTERACTIVE}" if [[ "${NONINTERACTIVE}" != "1" ]]; then - echo "Starting Happier Self-Host guided installation..." + info "Starting Happier Self-Host guided installation..." + say + info "This can take a few minutes. If it looks stuck, check logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + say +fi +if ! "${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}"; then + warn + warn "[self-host] install failed" + say + info "Troubleshooting:" + say " ${STACK_BIN_DIR}/hstack self-host status --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host doctor --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host config view --mode=${MODE} --channel=${CHANNEL} --json" + say + info "Logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.err.log" + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + exit 1 fi -"${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}" echo -echo "Happier Self-Host installation completed." -echo "Run: ${STACK_BIN_DIR}/hstack self-host status" +success "Happier Self-Host installation completed." +info "Run: ${STACK_BIN_DIR}/hstack self-host status" diff --git a/apps/website/public/self-host.sh b/apps/website/public/self-host.sh index bf4666e55..8838e036f 100644 --- a/apps/website/public/self-host.sh +++ b/apps/website/public/self-host.sh @@ -9,6 +9,9 @@ if [[ -n "${HAPPIER_SELF_HOST_MODE:-}" ]]; then fi WITH_CLI="${HAPPIER_WITH_CLI:-1}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_DATA="${HAPPIER_SELF_HOST_PURGE_DATA:-0}" HAPPIER_HOME="${HAPPIER_HOME:-${HOME}/.happier}" STACK_INSTALL_DIR="${HAPPIER_STACK_INSTALL_ROOT:-}" STACK_BIN_DIR="${HAPPIER_STACK_BIN_DIR:-}" @@ -22,6 +25,187 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never + +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} + +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" +} + +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} + +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} + +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +json_lookup_asset_url() { + local json="$1" + local name_regex="$2" + # GitHub API JSON is typically pretty-printed (newlines + spaces). Avoid "minifying" into one + # giant line (which can overflow awk line-length limits on some platforms) and instead parse + # line-by-line within the assets array. We intentionally return the *last* match to support + # rolling tags that may contain multiple versions: newest assets are appended later in the JSON. + printf '%s' "$json" | awk -v re="$name_regex" ' + BEGIN { + in_assets = 0 + name = "" + last = "" + } + { + raw = $0 + if (in_assets == 0) { + if (raw ~ /"assets"[[:space:]]*:[[:space:]]*\[/) { + in_assets = 1 + } + next + } + + # End of the assets array. The GitHub API pretty-prints `],` on its own line. + if (raw ~ /^[[:space:]]*][[:space:]]*,?[[:space:]]*$/) { + in_assets = 0 + next + } + + if (raw ~ /"name"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"name"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + if (q > 0) { + name = substr(v, 1, q - 1) + } + } + + if (raw ~ /"browser_download_url"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"browser_download_url"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + url = "" + if (q > 0) { + url = substr(v, 1, q - 1) + } + if (name ~ re && url != "") { + last = url + } + } + } + END { + if (last != "") { + print last + } + } + ' +} + +action_version() { + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local tag="stack-stable" + if [[ "${CHANNEL}" == "preview" ]]; then + tag="stack-preview" + fi + + local uname_os="" + uname_os="$(uname -s)" + local os="" + case "${uname_os}" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) + echo "Unsupported platform: ${uname_os}" >&2 + return 1 + ;; + esac + + local arch_raw="" + arch_raw="$(uname -m)" + local arch="" + case "${arch_raw}" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) + echo "Unsupported architecture: ${arch_raw}" >&2 + return 1 + ;; + esac + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + local release_json="" + if ! release_json="$(curl -fsSL "${api_url}")"; then + echo "Failed to fetch release metadata for Happier Stack." >&2 + return 1 + fi + + local asset_regex="^hstack-v.*-${os}-${arch}[.]tar[.]gz$" + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${os}-${arch} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#hstack-v}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "Happier Stack installer version check" + say "- channel: ${CHANNEL}" + say "- mode: ${MODE}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + usage() { cat <<'EOF' Usage: @@ -46,6 +230,14 @@ Options: --channel <stable|preview> --stable --preview + --check + --version + --reinstall + --restart + --uninstall [--purge-data] + --reset + --purge-data + --debug -h, --help EOF } @@ -117,6 +309,39 @@ while [[ $# -gt 0 ]]; do CHANNEL="preview" shift 1 ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_DATA="1" + shift 1 + ;; + --purge-data) + PURGE_DATA="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; --) shift 1 break @@ -129,6 +354,10 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + if [[ "${MODE}" != "user" && "${MODE}" != "system" ]]; then echo "Invalid mode: ${MODE}. Expected user or system." >&2 exit 1 @@ -139,6 +368,11 @@ if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then exit 1 fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi + UNAME="$(uname -s)" OS="" case "${UNAME}" in @@ -182,11 +416,6 @@ if [[ "${MODE}" == "system" && "${EUID}" -ne 0 ]]; then exit 1 fi -if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then - echo "systemctl is required for self-host installation on Linux." >&2 - exit 1 -fi - ARCH="$(uname -m)" case "${ARCH}" in x86_64|amd64) ARCH="x64" ;; @@ -202,52 +431,106 @@ if [[ "${CHANNEL}" == "preview" ]]; then TAG="stack-preview" fi -json_lookup_asset_url() { - local json="$1" - local name_regex="$2" - # GitHub API JSON is typically pretty-printed (newlines + spaces). Minify and then parse using a - # tiny jq-free state machine that pairs `"name":"..."` with the next `"browser_download_url":"..."`. - # We intentionally return the *last* match to support rolling tags that may contain multiple - # versions: newest assets are appended later in the release JSON. - printf '%s' "$json" | tr -d '[:space:]' | awk -v re="$name_regex" ' - { - s = $0 - assets_key = "\"assets\":[" - a = index(s, assets_key) - if (a > 0) { - s = substr(s, a + length(assets_key)) - } - name_key = "\"name\":\"" - url_key = "\"browser_download_url\":\"" - last = "" - while (1) { - p = index(s, name_key) - if (p == 0) break - s = substr(s, p + length(name_key)) - q = index(s, "\"") - if (q == 0) break - name = substr(s, 1, q - 1) - s = substr(s, q + 1) - - u = index(s, url_key) - if (u == 0) continue - s = substr(s, u + length(url_key)) - v = index(s, "\"") - if (v == 0) break - url = substr(s, 1, v - 1) - s = substr(s, v + 1) +resolve_hstack_path() { + if command -v hstack >/dev/null 2>&1; then + command -v hstack + return 0 + fi + if [[ -x "${STACK_INSTALL_DIR}/bin/hstack" ]]; then + echo "${STACK_INSTALL_DIR}/bin/hstack" + return 0 + fi + if [[ -x "${STACK_BIN_DIR}/hstack" ]]; then + echo "${STACK_BIN_DIR}/hstack" + return 0 + fi + return 1 +} - if (name ~ re && url != "") { - last = url - } - } - if (last != "") { - print last - } - } - ' +print_self_host_log_guidance() { + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi } +action_check() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + warn "Run: curl -fsSL https://happier.dev/self-host-preview | bash" + return 1 + fi + info "Checking Happier Self-Host..." + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + "${hstack}" self-host doctor --mode="${MODE}" --channel="${CHANNEL}" + return $? +} + +action_uninstall() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + return 1 + fi + local args=(self-host uninstall --yes --non-interactive --channel="${CHANNEL}" --mode="${MODE}") + if [[ "${PURGE_DATA}" == "1" ]]; then + args+=(--purge-data) + fi + "${hstack}" "${args[@]}" + return $? +} + +action_restart() { + local service="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + if [[ "${OS}" == "linux" ]] && command -v systemctl >/dev/null 2>&1; then + info "Restarting ${service}..." + if [[ "${MODE}" == "system" ]]; then + systemctl restart "${service}.service" + else + systemctl --user restart "${service}.service" + fi + fi + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -n "${hstack}" ]]; then + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + fi + return 0 +} + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi + +if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl is required for self-host installation on Linux." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -296,7 +579,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else if command -v unzip >/dev/null 2>&1; then unzip -q "${archive_path}" -d "${extract_dir}" @@ -345,7 +628,7 @@ write_minisign_public_key() { } API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." if ! RELEASE_JSON="$(curl -fsSL "${API_URL}")"; then if [[ "${CHANNEL}" == "stable" ]]; then echo "No stable releases found for Happier Stack." >&2 @@ -384,6 +667,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -393,7 +679,7 @@ CHECKSUMS_PATH="${TMP_DIR}/checksums.txt" curl -fsSL "${ASSET_URL}" -o "${ARCHIVE_PATH}" curl -fsSL "${CHECKSUMS_URL}" -o "${CHECKSUMS_PATH}" -EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1)" +EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1 || true)" if [[ -z "${EXPECTED_SHA}" ]]; then echo "Failed to resolve checksum for $(basename "${ASSET_URL}")" >&2 exit 1 @@ -403,7 +689,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -416,11 +702,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl -fsSL "${SIG_URL}" -o "${SIG_PATH}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name hstack -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then echo "Failed to locate extracted hstack binary." >&2 @@ -432,7 +718,7 @@ cp "${BINARY_PATH}" "${STACK_INSTALL_DIR}/bin/hstack" chmod +x "${STACK_INSTALL_DIR}/bin/hstack" ln -sf "${STACK_INSTALL_DIR}/bin/hstack" "${STACK_BIN_DIR}/hstack" -echo "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" +success "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" SELF_HOST_ARGS=(self-host install --non-interactive --channel="${CHANNEL}" --mode="${MODE}") if [[ "${WITH_CLI}" != "1" ]]; then @@ -442,10 +728,56 @@ fi export HAPPIER_NONINTERACTIVE="${NONINTERACTIVE}" if [[ "${NONINTERACTIVE}" != "1" ]]; then - echo "Starting Happier Self-Host guided installation..." + info "Starting Happier Self-Host guided installation..." + say + info "This can take a few minutes. If it looks stuck, check logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + say +fi +if ! "${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}"; then + warn + warn "[self-host] install failed" + say + info "Troubleshooting:" + say " ${STACK_BIN_DIR}/hstack self-host status --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host doctor --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host config view --mode=${MODE} --channel=${CHANNEL} --json" + say + info "Logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.err.log" + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + exit 1 fi -"${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}" echo -echo "Happier Self-Host installation completed." -echo "Run: ${STACK_BIN_DIR}/hstack self-host status" +success "Happier Self-Host installation completed." +info "Run: ${STACK_BIN_DIR}/hstack self-host status" diff --git a/docker/devcontainer/Dockerfile b/docker/dev-box/Dockerfile similarity index 93% rename from docker/devcontainer/Dockerfile rename to docker/dev-box/Dockerfile index 5e7a1387a..a2b0e82fc 100644 --- a/docker/devcontainer/Dockerfile +++ b/docker/dev-box/Dockerfile @@ -44,7 +44,7 @@ RUN yarn workspace @happier-dev/protocol postinstall:real \ && yarn workspace @happier-dev/release-runtime postinstall:real \ && yarn workspace @happier-dev/cli build -FROM node:${NODE_VERSION}-bookworm AS devcontainer +FROM node:${NODE_VERSION}-bookworm AS devbox RUN apt-get update && apt-get install -y --no-install-recommends \ git \ @@ -79,13 +79,13 @@ RUN useradd -m -s /bin/bash happier ENV NPM_CONFIG_PREFIX=/home/happier/.npm-global ENV PATH=/home/happier/.npm-global/bin:$PATH -COPY docker/devcontainer/entrypoint.sh /usr/local/bin/happier-devcontainer-entrypoint -RUN chmod +x /usr/local/bin/happier-devcontainer-entrypoint \ +COPY docker/dev-box/entrypoint.sh /usr/local/bin/happier-dev-box-entrypoint +RUN chmod +x /usr/local/bin/happier-dev-box-entrypoint \ && mkdir -p /home/happier/.npm-global \ && chown -R happier:happier /home/happier/.npm-global USER happier WORKDIR /workspace -ENTRYPOINT ["happier-devcontainer-entrypoint"] +ENTRYPOINT ["happier-dev-box-entrypoint"] CMD ["bash"] diff --git a/docker/devcontainer/entrypoint.sh b/docker/dev-box/entrypoint.sh similarity index 70% rename from docker/devcontainer/entrypoint.sh rename to docker/dev-box/entrypoint.sh index ad41bdba9..6e45982d5 100644 --- a/docker/devcontainer/entrypoint.sh +++ b/docker/dev-box/entrypoint.sh @@ -6,12 +6,12 @@ providers="$(printf "%s" "$providers_raw" | tr '[:upper:]' '[:lower:]' | tr -d ' if [ -n "$providers" ]; then if command -v hstack >/dev/null 2>&1; then - echo "[devcontainer] Installing provider CLIs via hstack: $providers" + echo "[dev-box] Installing provider CLIs via hstack: $providers" hstack providers install --providers="$providers" >/dev/null else # Fallback for older images: keep minimal install logic here. # (Prefer using hstack so the install recipes stay centralized.) - echo "[devcontainer] Warning: hstack not found; falling back to legacy provider install logic." >&2 + echo "[dev-box] Warning: hstack not found; falling back to legacy provider install logic." >&2 old_ifs="$IFS" IFS="," for p in $providers; do @@ -22,25 +22,25 @@ if [ -n "$providers" ]; then if command -v claude >/dev/null 2>&1; then continue fi - echo "[devcontainer] Installing Claude Code CLI (native installer)..." + echo "[dev-box] Installing Claude Code CLI (native installer)..." curl -fsSL https://claude.ai/install.sh | bash ;; "codex" ) if command -v codex >/dev/null 2>&1; then continue fi - echo "[devcontainer] Installing OpenAI Codex CLI (@openai/codex)..." + echo "[dev-box] Installing OpenAI Codex CLI (@openai/codex)..." npm install -g @openai/codex ;; "gemini" ) if command -v gemini >/dev/null 2>&1; then continue fi - echo "[devcontainer] Installing Google Gemini CLI (@google/gemini-cli)..." + echo "[dev-box] Installing Google Gemini CLI (@google/gemini-cli)..." npm install -g @google/gemini-cli ;; * ) - echo "[devcontainer] Unknown provider CLI: $p" >&2 + echo "[dev-box] Unknown provider CLI: $p" >&2 return 1 ;; esac diff --git a/docker/dockerhub/dev-box.md b/docker/dockerhub/dev-box.md new file mode 100644 index 000000000..97742f4ef --- /dev/null +++ b/docker/dockerhub/dev-box.md @@ -0,0 +1,29 @@ +# happierdev/dev-box + +Run Happier CLI + daemon inside a container, and pair it to your account without opening a browser. + +Quick start (preview): + +```bash +docker run --rm -it happierdev/dev-box:preview +``` + +Recommended: persist `~/.happier` so credentials and machine state survive restarts: + +```bash +docker run --rm -it \ + -v happier-home:/home/happier/.happier \ + happierdev/dev-box:preview +``` + +Optional: install provider CLIs on first boot: + +```bash +docker run --rm -it \ + -e HAPPIER_PROVIDER_CLIS=claude,codex \ + happierdev/dev-box:preview +``` + +Docs: + +- Dev Containers: https://docs.happier.dev/development/devcontainers diff --git a/docker/dockerhub/relay-server.md b/docker/dockerhub/relay-server.md new file mode 100644 index 000000000..b49f6d42c --- /dev/null +++ b/docker/dockerhub/relay-server.md @@ -0,0 +1,27 @@ +# happierdev/relay-server + +Self-host the Happier Server with Docker. + +Quick start (preview): + +```bash +docker run --rm -p 3005:3005 \ + -v happier-server-data:/data \ + happierdev/relay-server:preview +``` + +What you get: + +- Happier Server (self-host-friendly defaults: light flavor + SQLite) +- Embedded web UI served at `/` by default +- Persistent state under `/data` (mount a volume) + +Common options: + +- Disable UI serving: `-e HAPPIER_SERVER_UI_DIR=` +- Serve UI under `/ui`: `-e HAPPIER_SERVER_UI_PREFIX=/ui` +- Use Postgres: `-e HAPPIER_DB_PROVIDER=postgres -e DATABASE_URL=...` + +Docs: + +- Docker deployment: https://docs.happier.dev/deployment/docker diff --git a/package.json b/package.json index 9ec0d3adb..e193367b7 100644 --- a/package.json +++ b/package.json @@ -8,19 +8,46 @@ "start": "node ./apps/stack/scripts/repo_local.mjs start", "build": "node ./apps/stack/scripts/repo_local.mjs build", "tui": "node ./apps/stack/scripts/repo_local.mjs tui", + "tui:with-mobile": "node ./apps/stack/scripts/repo_local.mjs tui dev --mobile", + "cli:activate": "node ./apps/stack/scripts/repo_cli_activate.mjs", + "cli:activate:path": "node ./apps/stack/scripts/repo_cli_activate.mjs --install-path", "auth": "node ./apps/stack/scripts/repo_local.mjs auth", "daemon": "node ./apps/stack/scripts/repo_local.mjs daemon", "eas": "node ./apps/stack/scripts/repo_local.mjs eas", "happier": "node ./apps/stack/scripts/repo_local.mjs happier", + "ghops": "node ./apps/stack/scripts/ghops.mjs", "menubar": "node ./apps/stack/scripts/repo_local.mjs menubar", "mobile": "node ./apps/stack/scripts/repo_local.mjs mobile", + "mobile:install": "node ./apps/stack/scripts/repo_local.mjs mobile:install", "mobile-dev-client": "node ./apps/stack/scripts/repo_local.mjs mobile-dev-client", "providers": "node ./apps/stack/scripts/repo_local.mjs providers", "self-host": "node ./apps/stack/scripts/repo_local.mjs self-host", "remote": "node ./apps/stack/scripts/repo_local.mjs remote", "setup": "node ./apps/stack/scripts/repo_local.mjs setup", "service": "node ./apps/stack/scripts/repo_local.mjs service", + "service:status": "node ./apps/stack/scripts/repo_local.mjs service status", + "service:logs": "node ./apps/stack/scripts/repo_local.mjs service logs", + "service:tail": "node ./apps/stack/scripts/repo_local.mjs service tail", + "service:install": "yarn build && node ./apps/stack/scripts/repo_local.mjs service install", + "service:install:user": "yarn build && node ./apps/stack/scripts/repo_local.mjs service install --mode=user", + "service:install:system": "yarn build && node ./apps/stack/scripts/repo_local.mjs service install --mode=system", + "service:uninstall": "node ./apps/stack/scripts/repo_local.mjs service uninstall", + "service:restart": "node ./apps/stack/scripts/repo_local.mjs service restart", + "service:enable": "node ./apps/stack/scripts/repo_local.mjs service enable", + "service:disable": "node ./apps/stack/scripts/repo_local.mjs service disable", + "logs": "node ./apps/stack/scripts/repo_local.mjs logs --follow", + "logs:all": "node ./apps/stack/scripts/repo_local.mjs logs --follow --component=all", + "logs:server": "node ./apps/stack/scripts/repo_local.mjs logs --follow --component=server", + "logs:expo": "node ./apps/stack/scripts/repo_local.mjs logs --follow --component=expo", + "logs:ui": "node ./apps/stack/scripts/repo_local.mjs logs --follow --component=ui", + "logs:daemon": "node ./apps/stack/scripts/repo_local.mjs logs --follow --component=daemon", + "logs:service": "node ./apps/stack/scripts/repo_local.mjs logs --follow --component=service", "tailscale": "node ./apps/stack/scripts/repo_local.mjs tailscale", + "tailscale:status": "node ./apps/stack/scripts/repo_local.mjs tailscale status", + "tailscale:enable": "node ./apps/stack/scripts/repo_local.mjs tailscale enable", + "tailscale:disable": "node ./apps/stack/scripts/repo_local.mjs tailscale disable", + "tailscale:url": "node ./apps/stack/scripts/repo_local.mjs tailscale url", + "env": "node ./apps/stack/scripts/repo_local.mjs env", "ci:act": "bash scripts/ci/run-act-tests.sh", "test": "yarn -s test:unit", "test:unit": "yarn workspace @happier-dev/protocol test && yarn workspace @happier-dev/agents test && yarn workspace @happier-dev/app test && yarn workspace @happier-dev/cli test:unit && yarn --cwd apps/server test:unit && yarn --cwd packages/relay-server test && yarn --cwd apps/stack test:unit", @@ -29,6 +56,7 @@ "test:e2e:core": "yarn -s test:e2e", "test:e2e:core:fast": "yarn workspace @happier-dev/tests test:core:fast", "test:e2e:core:slow": "yarn workspace @happier-dev/tests test:core:slow", + "test:e2e:ui": "yarn workspace @happier-dev/tests test:ui:e2e", "test:e2e:core:pglite": "HAPPIER_E2E_DB_PROVIDER=pglite yarn -s test:e2e", "test:e2e:core:sqlite": "HAPPIER_E2E_DB_PROVIDER=sqlite yarn -s test:e2e", "test:e2e:core:postgres:docker": "node packages/tests/scripts/run-extended-db-docker.mjs --db postgres --mode e2e", diff --git a/packages/agents/src/manifest.ts b/packages/agents/src/manifest.ts index 739ae3223..098c20a73 100644 --- a/packages/agents/src/manifest.ts +++ b/packages/agents/src/manifest.ts @@ -111,4 +111,13 @@ export const AGENTS_CORE = { }, resume: { vendorResume: 'unsupported', vendorResumeIdField: 'piSessionId', runtimeGate: null }, }, + copilot: { + id: 'copilot', + cliSubcommand: 'copilot', + detectKey: 'copilot', + flavorAliases: ['github-copilot', 'copilot-cli'], + cloudConnect: null, + connectedServices: null, + resume: { vendorResume: 'supported', vendorResumeIdField: 'copilotSessionId', runtimeGate: 'acpLoadSession' }, + }, } as const satisfies Record<AgentId, AgentCore>; diff --git a/packages/agents/src/models.ts b/packages/agents/src/models.ts index 058aa9a24..553ac9782 100644 --- a/packages/agents/src/models.ts +++ b/packages/agents/src/models.ts @@ -137,6 +137,13 @@ export const AGENT_MODEL_CONFIG: Readonly<Record<AgentId, AgentModelConfig>> = O defaultMode: 'default', allowedModes: ['default'], }, + copilot: { + supportsSelection: true, + nonAcpApplyScope: 'next_prompt', + acpModelConfigOptionId: 'model', + defaultMode: 'default', + allowedModes: ['default'], + }, }); export function getAgentModelConfig(agentId: AgentId): AgentModelConfig { diff --git a/packages/agents/src/providers/cliInstallSpecs.ts b/packages/agents/src/providers/cliInstallSpecs.ts index 2009fd825..1cfda6a68 100644 --- a/packages/agents/src/providers/cliInstallSpecs.ts +++ b/packages/agents/src/providers/cliInstallSpecs.ts @@ -157,9 +157,25 @@ export const PROVIDER_CLI_INSTALL_SPECS: Readonly<Record<AgentId, ProviderCliIns win32: [npmGlobal('@mariozechner/pi-coding-agent')], }, }, + copilot: { + id: 'copilot', + title: 'GitHub Copilot CLI', + binaries: ['copilot'], + docsUrl: 'https://github.com/github/copilot-cli', + install: { + darwin: [npmGlobal('@github/copilot')], + linux: [npmGlobal('@github/copilot')], + win32: [ + { + cmd: 'npm', + args: ['install', '-g', '@github/copilot'], + note: 'Requires WSL (Windows Subsystem for Linux). Run inside your WSL terminal.', + }, + ], + }, + }, } as const; export function getProviderCliInstallSpec(id: AgentId): ProviderCliInstallSpec { return PROVIDER_CLI_INSTALL_SPECS[id]; } - diff --git a/packages/agents/src/sessionModes.ts b/packages/agents/src/sessionModes.ts index 7d7767b3f..137e539b4 100644 --- a/packages/agents/src/sessionModes.ts +++ b/packages/agents/src/sessionModes.ts @@ -19,6 +19,7 @@ export const AGENT_SESSION_MODES: Readonly<Record<AgentId, AgentSessionModesKind kimi: 'none', kilo: 'acpAgentModes', pi: 'none', + copilot: 'acpAgentModes', }); export function getAgentSessionModesKind(agentId: AgentId): AgentSessionModesKind { diff --git a/packages/agents/src/types.ts b/packages/agents/src/types.ts index 90cd1787e..255708acc 100644 --- a/packages/agents/src/types.ts +++ b/packages/agents/src/types.ts @@ -1,4 +1,4 @@ -export const AGENT_IDS = ['claude', 'codex', 'opencode', 'gemini', 'auggie', 'qwen', 'kimi', 'kilo', 'pi'] as const; +export const AGENT_IDS = ['claude', 'codex', 'opencode', 'gemini', 'auggie', 'qwen', 'kimi', 'kilo', 'pi', 'copilot'] as const; export type AgentId = (typeof AGENT_IDS)[number]; export const PERMISSION_MODES = [ @@ -41,7 +41,8 @@ export type VendorResumeIdField = | 'qwenSessionId' | 'kimiSessionId' | 'kiloSessionId' - | 'piSessionId'; + | 'piSessionId' + | 'copilotSessionId'; export type CloudVendorKey = 'openai' | 'anthropic' | 'gemini'; export type CloudConnectTargetStatus = 'wired' | 'experimental'; diff --git a/packages/cli-common/src/service/manager.ts b/packages/cli-common/src/service/manager.ts index 12d01491d..397c921a5 100644 --- a/packages/cli-common/src/service/manager.ts +++ b/packages/cli-common/src/service/manager.ts @@ -1,6 +1,6 @@ import { dirname, join } from 'node:path'; import { chmod, mkdir, rename, writeFile } from 'node:fs/promises'; -import { spawnSync } from 'node:child_process'; +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; import { buildLaunchdPath, buildLaunchdPlistXml } from './launchd.js'; import { renderSystemdServiceUnit } from './systemd.js'; @@ -316,19 +316,53 @@ export async function applyServicePlan(plan: ServicePlan, options: Readonly<{ ru if (!commandExists(c.cmd, process.env.PATH)) { throw new Error(`[service] command not found: ${c.cmd}`); } - const res = spawnSync(c.cmd, [...c.args], { encoding: 'utf8', env: process.env }); + let res = spawnSync(c.cmd, [...c.args], { encoding: 'utf8', env: process.env }); if (res.error) { if (c.allowFail) continue; throw new Error(`[service] failed to run ${c.cmd}: ${res.error.message}`); } - const status = typeof res.status === 'number' ? res.status : null; + + let status = typeof res.status === 'number' ? res.status : null; + if (status !== 0 && !c.allowFail && shouldRetryLaunchctlKickstart({ cmd: c.cmd, args: c.args, status, stderr: res.stderr })) { + res = await retryLaunchctlKickstart({ cmd: c.cmd, args: c.args }); + status = typeof res.status === 'number' ? res.status : null; + } + if (status !== 0) { if (c.allowFail) continue; const stderr = String(res.stderr ?? '').trim(); - const suffix = stderr ? `\n${stderr}` : ''; - throw new Error(`[service] command failed (${status ?? 'unknown'}): ${c.cmd} ${c.args.join(' ')}${suffix}`.trim()); + const stdout = String(res.stdout ?? '').trim(); + const suffix = [stdout ? `stdout:\n${stdout}` : '', stderr ? `stderr:\n${stderr}` : ''].filter(Boolean).join('\n'); + const details = suffix ? `\n${suffix}` : ''; + throw new Error(`[service] command failed (${status ?? 'unknown'}): ${c.cmd} ${c.args.join(' ')}${details}`.trim()); + } + } +} + +function shouldRetryLaunchctlKickstart(params: Readonly<{ cmd: string; args: readonly string[]; status: number | null; stderr: unknown }>): boolean { + if (params.cmd !== 'launchctl') return false; + if (params.status !== 113) return false; + const args = Array.isArray(params.args) ? params.args : []; + if (args[0] !== 'kickstart') return false; + const stderr = String(params.stderr ?? ''); + return stderr.includes('Could not find service'); +} + +async function retryLaunchctlKickstart(params: Readonly<{ cmd: string; args: readonly string[] }>): Promise<SpawnSyncReturns<string>> { + const maxAttempts = 15; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + const res = spawnSync(params.cmd, [...params.args], { encoding: 'utf8', env: process.env }); + if (res.error) return res; + const status = typeof res.status === 'number' ? res.status : null; + if (status === 0) return res; + if (!shouldRetryLaunchctlKickstart({ cmd: params.cmd, args: params.args, status, stderr: res.stderr })) { + return res; } } + return spawnSync(params.cmd, [...params.args], { encoding: 'utf8', env: process.env }); } async function writeAtomicTextFile(path: string, contents: string, mode: number): Promise<void> { diff --git a/packages/cli-common/src/update/index.ts b/packages/cli-common/src/update/index.ts index 248dc8311..2734df7ee 100644 --- a/packages/cli-common/src/update/index.ts +++ b/packages/cli-common/src/update/index.ts @@ -1,6 +1,6 @@ import { spawnSync, spawn } from 'node:child_process'; import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { basename, dirname } from 'node:path'; export type UpdateCache = { checkedAt: number | null; @@ -202,11 +202,30 @@ export function writeUpdateCache(path: string, cache: UpdateCache): void { } } +export function resolveSpawnDetachedNodeInvocation(params: Readonly<{ execPath: string; script: string; args: string[] }>): { + file: string; + args: string[]; + isRuntime: boolean; +} { + const execPath = String(params.execPath ?? '').trim(); + const base = basename(execPath).toLowerCase(); + const isRuntime = base === 'node' || base === 'node.exe' || base === 'bun' || base === 'bun.exe'; + if (isRuntime) { + return { file: execPath, args: [params.script, ...params.args], isRuntime }; + } + return { file: execPath, args: [...params.args], isRuntime }; +} + export function spawnDetachedNode(params: Readonly<{ script: string; args: string[]; cwd: string; env: NodeJS.ProcessEnv }>): void { try { - const child = spawn(process.execPath, [params.script, ...params.args], { + const resolved = resolveSpawnDetachedNodeInvocation({ + execPath: process.execPath, + script: params.script, + args: params.args, + }); + const child = spawn(resolved.file, resolved.args, { stdio: 'ignore', - cwd: params.cwd, + cwd: resolved.isRuntime ? params.cwd : process.cwd(), env: { ...params.env }, detached: true, }); diff --git a/packages/cli-common/tests/update.test.mjs b/packages/cli-common/tests/update.test.mjs index 79ebdecec..0e56a5492 100644 --- a/packages/cli-common/tests/update.test.mjs +++ b/packages/cli-common/tests/update.test.mjs @@ -13,6 +13,7 @@ import { shouldNotifyUpdate, readUpdateCache, writeUpdateCache, + resolveSpawnDetachedNodeInvocation, } from '../dist/update/index.js'; test('normalizeSemverBase strips prerelease', () => { @@ -126,3 +127,33 @@ test('acquireSingleFlightLock prevents duplicate spawns until ttl expires', asyn await rm(dir, { recursive: true, force: true }); } }); + +test('resolveSpawnDetachedNodeInvocation uses script when execPath is a JS runtime', () => { + assert.deepEqual( + resolveSpawnDetachedNodeInvocation({ + execPath: '/usr/bin/node', + script: '/repo/apps/cli/dist/index.mjs', + args: ['self', 'check', '--quiet'], + }), + { file: '/usr/bin/node', args: ['/repo/apps/cli/dist/index.mjs', 'self', 'check', '--quiet'], isRuntime: true }, + ); + assert.deepEqual( + resolveSpawnDetachedNodeInvocation({ + execPath: '/usr/local/bin/bun', + script: '/repo/apps/cli/dist/index.mjs', + args: ['self', 'check', '--quiet'], + }), + { file: '/usr/local/bin/bun', args: ['/repo/apps/cli/dist/index.mjs', 'self', 'check', '--quiet'], isRuntime: true }, + ); +}); + +test('resolveSpawnDetachedNodeInvocation omits script when execPath is a self-contained CLI binary', () => { + assert.deepEqual( + resolveSpawnDetachedNodeInvocation({ + execPath: '/home/user/.happier/bin/happier', + script: '/$bunfs/dist/index.mjs', + args: ['self', 'check', '--quiet'], + }), + { file: '/home/user/.happier/bin/happier', args: ['self', 'check', '--quiet'], isRuntime: false }, + ); +}); diff --git a/packages/protocol/package.json b/packages/protocol/package.json index e4a50a587..d76d6b8fa 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -30,6 +30,10 @@ "types": "./dist/checklists.d.ts", "default": "./dist/checklists.js" }, + "./installables": { + "types": "./dist/installables.d.ts", + "default": "./dist/installables.js" + }, "./capabilities": { "types": "./dist/capabilities.d.ts", "default": "./dist/capabilities.js" @@ -55,7 +59,7 @@ "scripts": { "build": "node scripts/generate-embedded-feature-policies.mjs && tsc -p tsconfig.json", "test": "vitest run", - "pretest": "node scripts/generate-embedded-feature-policies.mjs", + "pretest": "yarn -s build", "pretypecheck": "node scripts/generate-embedded-feature-policies.mjs", "postinstall:real": "yarn -s build", "postinstall": "node -e \"const fs=require('fs');const cp=require('child_process');if(/(^|[\\\\\\\\/])node_modules([\\\\\\\\/]|$)/i.test(process.cwd()))process.exit(0);try{const gate=require('../../scripts/postinstall/shouldRunPostinstall.cjs');if(!gate.shouldRunPostinstall({workspace:'protocol',scope:process.env.HAPPIER_INSTALL_SCOPE||''}))process.exit(0);}catch{}const has=(p)=>{try{return fs.existsSync(p)}catch{return false}};if(!has('tsconfig.json')||!has('../agents/tsconfig.json'))process.exit(0);const run=(cmd,args)=>cp.spawnSync(cmd,args,{stdio:'inherit'});const npmExec=process.env.npm_execpath;let r;if(npmExec){r=run(process.execPath,[npmExec,'run','-s','postinstall:real']);}else{r=run(process.platform==='win32'?'yarn.cmd':'yarn',['-s','postinstall:real']);if(r.error&&process.platform==='win32'){r=run('yarn',['-s','postinstall:real']);}}if(r.error){console.error(r.error.message);process.exit(1);}process.exit(r.status??1);\"", diff --git a/packages/protocol/src/account/encryptionMode.test.ts b/packages/protocol/src/account/encryptionMode.test.ts new file mode 100644 index 000000000..76e6cbd6f --- /dev/null +++ b/packages/protocol/src/account/encryptionMode.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { + AccountEncryptionModeResponseSchema, + AccountEncryptionModeUpdateRequestSchema, +} from './encryptionMode.js'; + +describe('account/encryptionMode', () => { + it('parses GET /v1/account/encryption response payloads', () => { + const parsed = AccountEncryptionModeResponseSchema.safeParse({ + mode: 'plain', + updatedAt: 123, + }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.mode).toBe('plain'); + }); + + it('rejects invalid account encryption mode updates', () => { + const parsed = AccountEncryptionModeUpdateRequestSchema.safeParse({ + mode: 'nope', + }); + expect(parsed.success).toBe(false); + }); +}); + diff --git a/packages/protocol/src/account/encryptionMode.ts b/packages/protocol/src/account/encryptionMode.ts new file mode 100644 index 000000000..7ed33f894 --- /dev/null +++ b/packages/protocol/src/account/encryptionMode.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { AccountEncryptionModeSchema } from '../features/payload/capabilities/encryptionCapabilities.js'; + +export const AccountEncryptionModeResponseSchema = z.object({ + mode: AccountEncryptionModeSchema, + updatedAt: z.number().int().min(0), +}).strict(); + +export type AccountEncryptionModeResponse = z.infer<typeof AccountEncryptionModeResponseSchema>; + +export const AccountEncryptionModeUpdateRequestSchema = z.object({ + mode: AccountEncryptionModeSchema, +}).strict(); + +export type AccountEncryptionModeUpdateRequest = z.infer<typeof AccountEncryptionModeUpdateRequestSchema>; + diff --git a/packages/protocol/src/account/settings/accountSettings.ts b/packages/protocol/src/account/settings/accountSettings.ts index da1e5f5db..66c52ac52 100644 --- a/packages/protocol/src/account/settings/accountSettings.ts +++ b/packages/protocol/src/account/settings/accountSettings.ts @@ -4,18 +4,23 @@ import { ActionsSettingsV1Schema, type ActionsSettingsV1 } from '../../actions/a export const ACCOUNT_SETTINGS_SUPPORTED_SCHEMA_VERSION = 2; +export const ForegroundBehaviorSchema = z.enum(['full', 'silent', 'off']); +export type ForegroundBehavior = z.infer<typeof ForegroundBehaviorSchema>; + export const NotificationsSettingsV1Schema = z .object({ v: z.literal(1).default(1), pushEnabled: z.boolean().default(true), ready: z.boolean().default(true), permissionRequest: z.boolean().default(true), + foregroundBehavior: ForegroundBehaviorSchema.default('full'), }) .catch({ v: 1, pushEnabled: true, ready: true, permissionRequest: true, + foregroundBehavior: 'full', }); export type NotificationsSettingsV1 = z.infer<typeof NotificationsSettingsV1Schema>; diff --git a/packages/protocol/src/account/settings/index.ts b/packages/protocol/src/account/settings/index.ts index 351e1ef87..f736ecca9 100644 --- a/packages/protocol/src/account/settings/index.ts +++ b/packages/protocol/src/account/settings/index.ts @@ -1,12 +1,14 @@ export { ACCOUNT_SETTINGS_SUPPORTED_SCHEMA_VERSION, AccountSettingsSchema, + ForegroundBehaviorSchema, NotificationsSettingsV1Schema, DEFAULT_ACTIONS_SETTINGS_V1, DEFAULT_NOTIFICATIONS_SETTINGS_V1, accountSettingsParse, getNotificationsSettingsV1FromAccountSettings, type AccountSettings, + type ForegroundBehavior, type NotificationsSettingsV1, } from './accountSettings.js'; diff --git a/packages/protocol/src/bugReports.fallback.test.ts b/packages/protocol/src/bugReports.fallback.test.ts new file mode 100644 index 000000000..24bfcf2e1 --- /dev/null +++ b/packages/protocol/src/bugReports.fallback.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import * as protocol from './index.js'; + +describe('bug report fallback body formatting', () => { + it('formats a fallback issue body using the same section titles as the GitHub issue form', () => { + const fn = (protocol as unknown as { formatBugReportFallbackIssueBody?: unknown }).formatBugReportFallbackIssueBody; + expect(typeof fn).toBe('function'); + if (typeof fn !== 'function') return; + + const body = (fn as (input: any) => string)({ + summary: 'Sessions list flickers between online and inactive.', + currentBehavior: 'List rapidly changes state on refresh.', + expectedBehavior: 'List remains stable.', + reproductionSteps: ['Open Sessions', 'Pull to refresh'], + frequency: 'often', + severity: 'medium', + whatChangedRecently: 'Updated from 0.12.2 → 0.12.3.', + diagnosticsIncluded: false, + environment: { + appVersion: '0.12.3', + platform: 'ios', + osVersion: '18.2', + deviceModel: 'iPhone16,2', + deploymentType: 'cloud', + serverUrl: 'https://api.happier.dev', + serverVersion: '0.12.3', + }, + }); + + expect(body).toContain('### Summary'); + expect(body).toContain('### What happened (current behavior)'); + expect(body).toContain('### Expected behavior'); + expect(body).toContain('### Reproduction steps'); + expect(body).toContain('### Frequency'); + expect(body).toContain('### Severity'); + expect(body).toContain('### Happier version'); + expect(body).toContain('### Platform'); + expect(body).toContain('### Server version'); + expect(body).toContain('### Deployment type'); + expect(body).toContain('### Diagnostics'); + expect(body).toContain('### What changed recently?'); + }); +}); + diff --git a/packages/protocol/src/bugReports/fallback.ts b/packages/protocol/src/bugReports/fallback.ts index 26262c570..958eaf7be 100644 --- a/packages/protocol/src/bugReports/fallback.ts +++ b/packages/protocol/src/bugReports/fallback.ts @@ -41,36 +41,49 @@ export function formatBugReportFallbackIssueBody(input: { ? reproductionSteps.map((step, index) => `${index + 1}. ${String(step ?? '').trim()}`).filter(Boolean).join('\n') : null; + const platformLines = [ + input.environment.platform, + input.environment.osVersion ? `- OS: ${input.environment.osVersion}` : null, + input.environment.deviceModel ? `- Device: ${input.environment.deviceModel}` : null, + ].filter((line): line is string => Boolean(line)); + const platform = platformLines.join('\n'); + return [ - '## Summary', + '### Summary', summary, '', - currentBehavior ? '## Current Behavior' : null, + currentBehavior ? '### What happened (current behavior)' : null, currentBehavior ?? null, currentBehavior ? '' : null, - expectedBehavior ? '## Expected Behavior' : null, + expectedBehavior ? '### Expected behavior' : null, expectedBehavior ?? null, expectedBehavior ? '' : null, - steps ? '## Reproduction Steps' : null, + steps ? '### Reproduction steps' : null, steps, steps ? '' : null, - input.frequency || input.severity ? '## Frequency / Severity' : null, - input.frequency ? `- Frequency: ${input.frequency}` : null, - input.severity ? `- Severity: ${input.severity}` : null, - input.frequency || input.severity ? '' : null, - '## Environment', - `- App version: ${input.environment.appVersion}`, - `- Platform: ${input.environment.platform}`, - input.environment.osVersion ? `- OS: ${input.environment.osVersion}` : null, - input.environment.deviceModel ? `- Device: ${input.environment.deviceModel}` : null, - input.environment.serverVersion ? `- Server version: ${input.environment.serverVersion}` : null, - `- Deployment: ${input.environment.deploymentType}`, + input.frequency ? '### Frequency' : null, + input.frequency ?? null, + input.frequency ? '' : null, + input.severity ? '### Severity' : null, + input.severity ?? null, + input.severity ? '' : null, + '### Happier version', + input.environment.appVersion, + '', + '### Platform', + platform, + '', + input.environment.serverVersion ? '### Server version' : null, + input.environment.serverVersion ?? null, + input.environment.serverVersion ? '' : null, + '### Deployment type', + input.environment.deploymentType, '', - '## Diagnostics', + '### Diagnostics', `- Diagnostics: ${input.diagnosticsIncluded ? 'included' : 'not included'}`, '- Diagnostics artifacts are unavailable from this fallback flow.', '', - input.whatChangedRecently?.trim() ? '## What changed recently' : null, + input.whatChangedRecently?.trim() ? '### What changed recently?' : null, input.whatChangedRecently?.trim() ? input.whatChangedRecently.trim() : null, ].filter((line): line is string => Boolean(line)).join('\n'); } diff --git a/packages/protocol/src/encryption/storagePolicyDecisions.test.ts b/packages/protocol/src/encryption/storagePolicyDecisions.test.ts new file mode 100644 index 000000000..96f4b7f2b --- /dev/null +++ b/packages/protocol/src/encryption/storagePolicyDecisions.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { + isSessionEncryptionModeAllowedByStoragePolicy, + isStoredContentKindAllowedForSessionByStoragePolicy, + resolveEffectiveDefaultAccountEncryptionMode, + resolveStoredContentKindForSessionEncryptionMode, +} from './storagePolicyDecisions.js'; + +describe('storagePolicyDecisions', () => { + it('required_e2ee allows only e2ee sessions with encrypted content', () => { + expect(isSessionEncryptionModeAllowedByStoragePolicy('required_e2ee', 'e2ee')).toBe(true); + expect(isSessionEncryptionModeAllowedByStoragePolicy('required_e2ee', 'plain')).toBe(false); + + expect(isStoredContentKindAllowedForSessionByStoragePolicy('required_e2ee', 'e2ee', 'encrypted')).toBe(true); + expect(isStoredContentKindAllowedForSessionByStoragePolicy('required_e2ee', 'e2ee', 'plain')).toBe(false); + expect(isStoredContentKindAllowedForSessionByStoragePolicy('required_e2ee', 'plain', 'plain')).toBe(false); + }); + + it('plaintext_only allows only plain sessions with plain content', () => { + expect(isSessionEncryptionModeAllowedByStoragePolicy('plaintext_only', 'plain')).toBe(true); + expect(isSessionEncryptionModeAllowedByStoragePolicy('plaintext_only', 'e2ee')).toBe(false); + + expect(isStoredContentKindAllowedForSessionByStoragePolicy('plaintext_only', 'plain', 'plain')).toBe(true); + expect(isStoredContentKindAllowedForSessionByStoragePolicy('plaintext_only', 'plain', 'encrypted')).toBe(false); + expect(isStoredContentKindAllowedForSessionByStoragePolicy('plaintext_only', 'e2ee', 'encrypted')).toBe(false); + }); + + it('optional allows both session modes but requires mode and content kind to match', () => { + expect(isSessionEncryptionModeAllowedByStoragePolicy('optional', 'e2ee')).toBe(true); + expect(isSessionEncryptionModeAllowedByStoragePolicy('optional', 'plain')).toBe(true); + + expect(isStoredContentKindAllowedForSessionByStoragePolicy('optional', 'e2ee', 'encrypted')).toBe(true); + expect(isStoredContentKindAllowedForSessionByStoragePolicy('optional', 'e2ee', 'plain')).toBe(false); + expect(isStoredContentKindAllowedForSessionByStoragePolicy('optional', 'plain', 'plain')).toBe(true); + expect(isStoredContentKindAllowedForSessionByStoragePolicy('optional', 'plain', 'encrypted')).toBe(false); + }); + + it('maps session encryption modes to stored content kinds', () => { + expect(resolveStoredContentKindForSessionEncryptionMode('e2ee')).toBe('encrypted'); + expect(resolveStoredContentKindForSessionEncryptionMode('plain')).toBe('plain'); + }); + + it('resolves an effective default account mode for each storage policy', () => { + expect(resolveEffectiveDefaultAccountEncryptionMode('required_e2ee', 'plain')).toBe('e2ee'); + expect(resolveEffectiveDefaultAccountEncryptionMode('plaintext_only', 'e2ee')).toBe('plain'); + expect(resolveEffectiveDefaultAccountEncryptionMode('optional', 'plain')).toBe('plain'); + expect(resolveEffectiveDefaultAccountEncryptionMode('optional', 'e2ee')).toBe('e2ee'); + }); +}); diff --git a/packages/protocol/src/encryption/storagePolicyDecisions.ts b/packages/protocol/src/encryption/storagePolicyDecisions.ts new file mode 100644 index 000000000..e5d2dd853 --- /dev/null +++ b/packages/protocol/src/encryption/storagePolicyDecisions.ts @@ -0,0 +1,47 @@ +import type { + AccountEncryptionMode, + EncryptionStoragePolicy, +} from '../features/payload/capabilities/encryptionCapabilities.js'; + +export type SessionEncryptionMode = AccountEncryptionMode; +export type SessionStoredContentKind = 'encrypted' | 'plain'; + +export function resolveStoredContentKindForSessionEncryptionMode( + mode: SessionEncryptionMode, +): SessionStoredContentKind { + return mode === 'plain' ? 'plain' : 'encrypted'; +} + +export function resolveEffectiveDefaultAccountEncryptionMode( + storagePolicy: EncryptionStoragePolicy, + configuredDefaultMode: AccountEncryptionMode, +): AccountEncryptionMode { + if (storagePolicy === 'required_e2ee') return 'e2ee'; + if (storagePolicy === 'plaintext_only') return 'plain'; + return configuredDefaultMode; +} + +export function isSessionEncryptionModeAllowedByStoragePolicy( + storagePolicy: EncryptionStoragePolicy, + mode: SessionEncryptionMode, +): boolean { + if (storagePolicy === 'required_e2ee') return mode === 'e2ee'; + if (storagePolicy === 'plaintext_only') return mode === 'plain'; + return true; +} + +export function isStoredContentKindAllowedForSessionByStoragePolicy( + storagePolicy: EncryptionStoragePolicy, + sessionEncryptionMode: SessionEncryptionMode, + contentKind: SessionStoredContentKind, +): boolean { + if (storagePolicy === 'required_e2ee') { + return sessionEncryptionMode === 'e2ee' && contentKind === 'encrypted'; + } + if (storagePolicy === 'plaintext_only') { + return sessionEncryptionMode === 'plain' && contentKind === 'plain'; + } + + const expected = resolveStoredContentKindForSessionEncryptionMode(sessionEncryptionMode); + return contentKind === expected; +} diff --git a/packages/protocol/src/features.payload.test.ts b/packages/protocol/src/features.payload.test.ts index 6d6f7e237..98491dc48 100644 --- a/packages/protocol/src/features.payload.test.ts +++ b/packages/protocol/src/features.payload.test.ts @@ -24,8 +24,15 @@ describe('FeaturesResponseSchema', () => { expect(parsed.features.voice.enabled).toBe(false); expect(parsed.features.voice.happierVoice.enabled).toBe(false); expect(parsed.features.social.friends.enabled).toBe(false); + expect(parsed.features.encryption.plaintextStorage.enabled).toBe(false); + expect(parsed.features.encryption.accountOptOut.enabled).toBe(false); expect(parsed.features.auth.recovery.providerReset.enabled).toBe(false); + expect((parsed as any).features.auth.mtls.enabled).toBe(false); + // Backward compatibility: older servers predate this gate but still support `POST /v1/auth`. + // Default to enabled unless a server explicitly disables it. + expect(parsed.features.auth.login.keyChallenge.enabled).toBe(true); expect(parsed.features.auth.ui.recoveryKeyReminder.enabled).toBe(false); + expect((parsed as any).features.e2ee.keylessAccounts.enabled).toBe(false); expect(parsed.capabilities.bugReports).toEqual(DEFAULT_BUG_REPORTS_CAPABILITIES); expect(parsed.capabilities.voice).toEqual({ @@ -35,9 +42,45 @@ describe('FeaturesResponseSchema', () => { disabledByBuildPolicy: false, }); expect(parsed.capabilities.oauth.providers).toEqual({}); + expect(parsed.capabilities.encryption).toEqual({ + storagePolicy: 'required_e2ee', + allowAccountOptOut: false, + defaultAccountMode: 'e2ee', + }); + expect((parsed as any).capabilities.auth.methods).toEqual([]); + expect(parsed.capabilities.auth.login.methods).toEqual([]); + expect(parsed.capabilities.auth.mtls).toEqual({ + mode: 'forwarded', + autoProvision: false, + identitySource: 'san_email', + policy: { + trustForwardedHeaders: false, + issuerAllowlist: { enabled: false, count: 0 }, + emailDomainAllowlist: { enabled: false, count: 0 }, + }, + }); expect(parsed.capabilities.auth.misconfig).toEqual([]); }); + it('accepts legacy payloads that omit auth.login.methods', () => { + const parsed = FeaturesResponseSchema.parse({ + features: {}, + capabilities: { + auth: { + signup: { methods: [{ id: 'anonymous', enabled: true }] }, + login: { requiredProviders: [] }, + recovery: { providerReset: { providers: [] } }, + ui: { autoRedirect: { enabled: false, providerId: null } }, + providers: {}, + misconfig: [], + }, + }, + }); + + expect(parsed.capabilities.auth.login.methods).toEqual([]); + expect((parsed as any).capabilities.auth.methods).toEqual([]); + }); + it('coerces bug reports capabilities from sparse payloads', () => { const coerced = coerceBugReportsCapabilitiesFromFeaturesPayload({ capabilities: { diff --git a/packages/protocol/src/features/catalog.ts b/packages/protocol/src/features/catalog.ts index e9d87cc66..5ed39d0ed 100644 --- a/packages/protocol/src/features/catalog.ts +++ b/packages/protocol/src/features/catalog.ts @@ -99,12 +99,42 @@ const FEATURE_CATALOG_DEFINITION = { dependencies: [], representation: 'server', }, + 'auth.login.keyChallenge': { + description: 'Key-challenge login route availability (POST /v1/auth).', + defaultFailMode: 'fail_closed', + dependencies: [], + representation: 'server', + }, + 'auth.mtls': { + description: 'mTLS client certificate authentication support.', + defaultFailMode: 'fail_closed', + dependencies: [], + representation: 'server', + }, 'auth.ui.recoveryKeyReminder': { description: 'Recovery key reminder UI behavior.', defaultFailMode: 'fail_closed', dependencies: [], representation: 'server', }, + 'encryption.plaintextStorage': { + description: 'Plaintext session storage support (no E2EE at rest).', + defaultFailMode: 'fail_closed', + dependencies: [], + representation: 'server', + }, + 'encryption.accountOptOut': { + description: 'Per-account encryption opt-out toggle support.', + defaultFailMode: 'fail_closed', + dependencies: ['encryption.plaintextStorage'], + representation: 'server', + }, + 'e2ee.keylessAccounts': { + description: 'Keyless account support (accounts may omit E2EE signing keys).', + defaultFailMode: 'fail_closed', + dependencies: [], + representation: 'server', + }, 'app.analytics': { description: 'Anonymous analytics and instrumentation (PostHog).', defaultFailMode: 'fail_closed', diff --git a/packages/protocol/src/features/payload/capabilities/authCapabilities.ts b/packages/protocol/src/features/payload/capabilities/authCapabilities.ts index 30f9a6be4..36bd1a914 100644 --- a/packages/protocol/src/features/payload/capabilities/authCapabilities.ts +++ b/packages/protocol/src/features/payload/capabilities/authCapabilities.ts @@ -1,10 +1,29 @@ import { z } from 'zod'; +const AuthMethodActionSchema = z.object({ + id: z.enum(['login', 'provision', 'connect']), + enabled: z.boolean(), + mode: z.enum(['keyed', 'keyless', 'either']), +}); + +export const AuthMethodSchema = z.object({ + id: z.string(), + actions: z.array(AuthMethodActionSchema), + ui: z + .object({ + displayName: z.string().optional(), + iconHint: z.string().nullable().optional(), + }) + .optional(), +}); + export const AuthCapabilitiesSchema = z.object({ + methods: z.array(AuthMethodSchema).optional().default([]), signup: z.object({ methods: z.array(z.object({ id: z.string(), enabled: z.boolean() })), }), login: z.object({ + methods: z.array(z.object({ id: z.string(), enabled: z.boolean() })).optional().default([]), requiredProviders: z.array(z.string()), }), recovery: z.object({ @@ -54,15 +73,56 @@ export const AuthCapabilitiesSchema = z.object({ envVars: z.array(z.string()).optional(), }), ), + mtls: z + .object({ + mode: z.enum(['forwarded', 'direct']), + autoProvision: z.boolean(), + identitySource: z.enum(['san_email', 'san_upn', 'subject_cn', 'fingerprint']), + policy: z + .object({ + trustForwardedHeaders: z.boolean(), + issuerAllowlist: z.object({ + enabled: z.boolean(), + count: z.number().int().min(0), + }), + emailDomainAllowlist: z.object({ + enabled: z.boolean(), + count: z.number().int().min(0), + }), + }) + .optional(), + }) + .optional() + .default({ + mode: 'forwarded', + autoProvision: false, + identitySource: 'san_email', + policy: { + trustForwardedHeaders: false, + issuerAllowlist: { enabled: false, count: 0 }, + emailDomainAllowlist: { enabled: false, count: 0 }, + }, + }), }); export type AuthCapabilities = z.infer<typeof AuthCapabilitiesSchema>; export const DEFAULT_AUTH_CAPABILITIES: AuthCapabilities = { + methods: [], signup: { methods: [] }, - login: { requiredProviders: [] }, + login: { methods: [], requiredProviders: [] }, recovery: { providerReset: { providers: [] } }, ui: { autoRedirect: { enabled: false, providerId: null } }, providers: {}, misconfig: [], + mtls: { + mode: 'forwarded', + autoProvision: false, + identitySource: 'san_email', + policy: { + trustForwardedHeaders: false, + issuerAllowlist: { enabled: false, count: 0 }, + emailDomainAllowlist: { enabled: false, count: 0 }, + }, + }, }; diff --git a/packages/protocol/src/features/payload/capabilities/capabilitiesSchema.ts b/packages/protocol/src/features/payload/capabilities/capabilitiesSchema.ts index be728d5f4..0b2ad9c3c 100644 --- a/packages/protocol/src/features/payload/capabilities/capabilitiesSchema.ts +++ b/packages/protocol/src/features/payload/capabilities/capabilitiesSchema.ts @@ -14,10 +14,15 @@ import { AuthCapabilitiesSchema, DEFAULT_AUTH_CAPABILITIES, } from './authCapabilities.js'; +import { + DEFAULT_ENCRYPTION_CAPABILITIES, + EncryptionCapabilitiesSchema, +} from './encryptionCapabilities.js'; export const CapabilitiesSchema = z.object({ bugReports: BugReportsCapabilitiesSchema.optional().default(DEFAULT_BUG_REPORTS_CAPABILITIES), voice: VoiceCapabilitiesSchema.optional().default(DEFAULT_VOICE_CAPABILITIES), + encryption: EncryptionCapabilitiesSchema.optional().default(DEFAULT_ENCRYPTION_CAPABILITIES), social: z .object({ friends: SocialFriendsCapabilitiesSchema.optional().default(DEFAULT_SOCIAL_FRIENDS_CAPABILITIES), diff --git a/packages/protocol/src/features/payload/capabilities/encryptionCapabilities.ts b/packages/protocol/src/features/payload/capabilities/encryptionCapabilities.ts new file mode 100644 index 000000000..e6f18ef4d --- /dev/null +++ b/packages/protocol/src/features/payload/capabilities/encryptionCapabilities.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const EncryptionStoragePolicySchema = z.enum(['required_e2ee', 'optional', 'plaintext_only']); +export type EncryptionStoragePolicy = z.infer<typeof EncryptionStoragePolicySchema>; + +export const AccountEncryptionModeSchema = z.enum(['e2ee', 'plain']); +export type AccountEncryptionMode = z.infer<typeof AccountEncryptionModeSchema>; + +export const EncryptionCapabilitiesSchema = z.object({ + storagePolicy: EncryptionStoragePolicySchema, + allowAccountOptOut: z.boolean(), + defaultAccountMode: AccountEncryptionModeSchema, +}); + +export type EncryptionCapabilities = z.infer<typeof EncryptionCapabilitiesSchema>; + +export const DEFAULT_ENCRYPTION_CAPABILITIES: EncryptionCapabilities = { + storagePolicy: 'required_e2ee', + allowAccountOptOut: false, + defaultAccountMode: 'e2ee', +}; + diff --git a/packages/protocol/src/features/payload/featureGatesSchema.ts b/packages/protocol/src/features/payload/featureGatesSchema.ts index 9f72d2c40..69abc23ce 100644 --- a/packages/protocol/src/features/payload/featureGatesSchema.ts +++ b/packages/protocol/src/features/payload/featureGatesSchema.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { FeatureGateSchema, type FeatureGate } from './featureGate.js'; const DEFAULT_GATE_DISABLED: FeatureGate = { enabled: false }; +const DEFAULT_GATE_ENABLED: FeatureGate = { enabled: true }; const VoiceGateSchema = z.object({ enabled: z.boolean(), @@ -11,6 +12,19 @@ const VoiceGateSchema = z.object({ export const FeatureGatesSchema = z.object({ bugReports: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), + e2ee: z + .object({ + keylessAccounts: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), + }) + .optional() + .default({ keylessAccounts: DEFAULT_GATE_DISABLED }), + encryption: z + .object({ + plaintextStorage: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), + accountOptOut: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), + }) + .optional() + .default({ plaintextStorage: DEFAULT_GATE_DISABLED, accountOptOut: DEFAULT_GATE_DISABLED }), attachments: z .object({ uploads: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), @@ -65,6 +79,15 @@ export const FeatureGatesSchema = z.object({ }) .optional() .default({ providerReset: DEFAULT_GATE_DISABLED }), + mtls: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), + login: z + .object({ + // Backward compatibility: older servers predate this gate but still support key-challenge login. + // Default to enabled unless a server explicitly disables it. + keyChallenge: FeatureGateSchema.optional().default(DEFAULT_GATE_ENABLED), + }) + .optional() + .default({ keyChallenge: DEFAULT_GATE_ENABLED }), ui: z .object({ recoveryKeyReminder: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), @@ -75,6 +98,8 @@ export const FeatureGatesSchema = z.object({ .optional() .default({ recovery: { providerReset: DEFAULT_GATE_DISABLED }, + mtls: DEFAULT_GATE_DISABLED, + login: { keyChallenge: DEFAULT_GATE_ENABLED }, ui: { recoveryKeyReminder: DEFAULT_GATE_DISABLED }, }), }); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 71d621319..21aa3c225 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -50,6 +50,20 @@ export { buildConnectedServiceCredentialRecord } from './connect/buildConnectedS export { parseBooleanEnv, parseOptionalBooleanEnv } from './env/parseBooleanEnv.js'; +export { + SessionStoredMessageContentSchema, + type SessionStoredMessageContent, +} from './sessionMessages/sessionStoredMessageContent.js'; + +export { + isSessionEncryptionModeAllowedByStoragePolicy, + isStoredContentKindAllowedForSessionByStoragePolicy, + resolveEffectiveDefaultAccountEncryptionMode, + resolveStoredContentKindForSessionEncryptionMode, + type SessionEncryptionMode, + type SessionStoredContentKind, +} from './encryption/storagePolicyDecisions.js'; + export { BOX_BUNDLE_MIN_BYTES, BOX_BUNDLE_NONCE_BYTES, @@ -105,6 +119,19 @@ export { type RpcErrorCarrier, } from './rpcErrors.js'; export { CHECKLIST_IDS, resumeChecklistId, type ChecklistId } from './checklists.js'; +export { + INSTALLABLES_CATALOG, + INSTALLABLE_KEYS, + CODEX_ACP_DEP_ID, + CODEX_ACP_DIST_TAG, + CODEX_MCP_RESUME_DEP_ID, + CODEX_MCP_RESUME_DIST_TAG, + type InstallableAutoUpdateMode, + type InstallableCatalogEntry, + type InstallableDefaultPolicy, + type InstallableKey, + type InstallableKind, +} from './installables.js'; export { SOCKET_RPC_EVENTS, type SocketRpcEvent } from './socketRpc.js'; export { ChangeEntrySchema, @@ -148,7 +175,7 @@ export { type UpdateStateAckResponse, } from './updates.js'; -export { SENT_FROM_VALUES, SentFromSchema, type SentFrom } from './sentFrom.js'; +export { SENT_FROM_VALUES, SentFromSchema, createSentFromSchema, type SentFrom } from './sentFrom.js'; export { AuthStatusEnvelopeSchema, AuthStatusResultSchema, @@ -194,6 +221,15 @@ export { SessionStatusResultSchema, SessionStopEnvelopeSchema, SessionStopResultSchema, + SessionShareSchema, + V2SessionByIdNotFoundSchema, + V2SessionByIdResponseSchema, + V2SessionListResponseSchema, + V2SessionMessageResponseSchema, + V2SessionRecordSchema, + V2_SESSION_LIST_CURSOR_V1_PREFIX, + decodeV2SessionListCursorV1, + encodeV2SessionListCursorV1, SessionSummarySchema, SessionWaitEnvelopeSchema, SessionWaitResultSchema, @@ -222,9 +258,58 @@ export { type SessionSendResult, type SessionStatusResult, type SessionStopResult, + type SessionShare, + type V2SessionByIdNotFound, + type V2SessionByIdResponse, + type V2SessionListResponse, + type V2SessionMessageResponse, + type V2SessionRecord, type SessionSummary, + SessionMetadataSchema, + type SessionMetadata, + SessionSystemSessionV1Schema, + type SessionSystemSessionV1, + createSessionMetadataSchema, + createSessionSystemSessionV1Schema, + isHiddenSystemSession, + readSystemSessionMetadataFromMetadata, + buildSystemSessionMetadataV1, type SessionWaitResult, } from './sessionControl/contract.js'; + +export { + ModelOverrideV1Schema, + type ModelOverrideV1, + createModelOverrideV1Schema, + buildModelOverrideV1, + AcpSessionModeOverrideV1Schema, + type AcpSessionModeOverrideV1, + createAcpSessionModeOverrideV1Schema, + buildAcpSessionModeOverrideV1, + AcpConfigOptionOverridesV1Schema, + type AcpConfigOptionOverridesV1, + createAcpConfigOptionOverridesV1Schema, + buildAcpConfigOptionOverridesV1, +} from './sessionMetadata/metadataOverridesV1.js'; + +export { + SessionTerminalMetadataSchema, + type SessionTerminalMetadata, + createSessionTerminalMetadataSchema, +} from './sessionMetadata/terminalMetadata.js'; + +export { + SESSION_PERMISSION_MODES, + SessionPermissionModeSchema, + type SessionPermissionMode, + createSessionPermissionModeSchema, +} from './sessionMetadata/sessionPermissionModes.js'; + +export { + SessionMessageMetaSchema, + type SessionMessageMeta, + createSessionMessageMetaSchema, +} from './sessionMessages/sessionMessageMeta.js'; export { ServerAddEnvelopeSchema, ServerAddResultSchema, @@ -262,6 +347,9 @@ export { ScmCapabilitiesSchema, ScmChangeApplyRequestSchema, ScmChangeApplyResponseSchema, + ScmChangeDiscardEntrySchema, + ScmChangeDiscardRequestSchema, + ScmChangeDiscardResponseSchema, ScmCommitBackoutRequestSchema, ScmCommitBackoutResponseSchema, ScmCommitPatchSchema, @@ -303,6 +391,9 @@ export { type ScmCapabilities, type ScmChangeApplyRequest, type ScmChangeApplyResponse, + type ScmChangeDiscardEntry, + type ScmChangeDiscardRequest, + type ScmChangeDiscardResponse, type ScmCommitBackoutRequest, type ScmCommitBackoutResponse, type ScmCommitPatch, @@ -669,6 +760,13 @@ export { type LinkedProvider, } from './account/profile.js'; +export { + AccountEncryptionModeResponseSchema, + AccountEncryptionModeUpdateRequestSchema, + type AccountEncryptionModeResponse, + type AccountEncryptionModeUpdateRequest, +} from './account/encryptionMode.js'; + export { ACCOUNT_SETTINGS_SUPPORTED_SCHEMA_VERSION, AccountSettingsSchema, diff --git a/packages/protocol/src/installables.test.ts b/packages/protocol/src/installables.test.ts new file mode 100644 index 000000000..de7df5cea --- /dev/null +++ b/packages/protocol/src/installables.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { INSTALLABLES_CATALOG } from './installables.js'; + +describe('installables catalog', () => { + it('has unique keys', () => { + const keys = INSTALLABLES_CATALOG.map((e) => e.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('has unique capability ids', () => { + const ids = INSTALLABLES_CATALOG.map((e) => e.capabilityId); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + diff --git a/packages/protocol/src/installables.ts b/packages/protocol/src/installables.ts new file mode 100644 index 000000000..25abb2235 --- /dev/null +++ b/packages/protocol/src/installables.ts @@ -0,0 +1,58 @@ +import type { CapabilityId } from './capabilities.js'; + +export type InstallableKind = 'dep'; + +export type InstallableAutoUpdateMode = 'off' | 'notify' | 'auto'; + +export type InstallableDefaultPolicy = Readonly<{ + autoInstallWhenNeeded: boolean; + autoUpdateMode: InstallableAutoUpdateMode; +}>; + +export type InstallableCatalogEntry = Readonly<{ + key: string; + kind: InstallableKind; + capabilityId: Extract<CapabilityId, `dep.${string}`>; + /** + * Optional npm dist-tag used by the capability detect registry check. + * This is a metadata default; consumers may override when necessary. + */ + defaultDistTag: string; + defaultPolicy: InstallableDefaultPolicy; + experimental: boolean; +}>; + +export const INSTALLABLE_KEYS = { + CODEX_MCP_RESUME: 'codex-mcp-resume', + CODEX_ACP: 'codex-acp', +} as const; + +export type InstallableKey = typeof INSTALLABLE_KEYS[keyof typeof INSTALLABLE_KEYS]; + +export const CODEX_MCP_RESUME_DEP_ID = 'dep.codex-mcp-resume' as const satisfies CapabilityId; +export const CODEX_MCP_RESUME_DIST_TAG = 'happy-codex-resume' as const; + +export const CODEX_ACP_DEP_ID = 'dep.codex-acp' as const satisfies CapabilityId; +export const CODEX_ACP_DIST_TAG = 'latest' as const; + +const DEFAULT_POLICY: InstallableDefaultPolicy = { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }; + +export const INSTALLABLES_CATALOG = [ + { + key: INSTALLABLE_KEYS.CODEX_MCP_RESUME, + kind: 'dep', + capabilityId: CODEX_MCP_RESUME_DEP_ID, + defaultDistTag: CODEX_MCP_RESUME_DIST_TAG, + defaultPolicy: DEFAULT_POLICY, + experimental: true, + }, + { + key: INSTALLABLE_KEYS.CODEX_ACP, + kind: 'dep', + capabilityId: CODEX_ACP_DEP_ID, + defaultDistTag: CODEX_ACP_DIST_TAG, + defaultPolicy: DEFAULT_POLICY, + experimental: true, + }, +] as const satisfies readonly InstallableCatalogEntry[]; + diff --git a/packages/protocol/src/scm.contract.test.ts b/packages/protocol/src/scm.contract.test.ts index 9dab39054..9f1371fb0 100644 --- a/packages/protocol/src/scm.contract.test.ts +++ b/packages/protocol/src/scm.contract.test.ts @@ -5,6 +5,8 @@ import { SCM_COMMIT_PATCH_MAX_LENGTH, SCM_OPERATION_ERROR_CODES, ScmBackendDescribeResponseSchema, + ScmChangeDiscardRequestSchema, + ScmChangeDiscardResponseSchema, ScmCommitCreateRequestSchema, isScmPatchBoundToPath, parseScmPatchPaths, @@ -159,6 +161,26 @@ describe('scm protocol contracts', () => { expect(patchScoped.patches?.[0]?.path).toBe('src/a.ts'); }); + it('supports discarding a set of pending changes', () => { + const parsedRequest = ScmChangeDiscardRequestSchema.parse({ + cwd: '.', + entries: [ + { path: 'src/a.ts', kind: 'modified' }, + { path: 'src/new.ts', kind: 'untracked' }, + ], + }); + expect(parsedRequest.entries).toHaveLength(2); + expect(parsedRequest.entries[0]?.path).toBe('src/a.ts'); + expect(parsedRequest.entries[0]?.kind).toBe('modified'); + + const parsedResponse = ScmChangeDiscardResponseSchema.parse({ + success: true, + stdout: '', + stderr: '', + }); + expect(parsedResponse.success).toBe(true); + }); + it('enforces commit patch count and size limits', () => { const oversizedPatch = ScmCommitCreateRequestSchema.safeParse({ cwd: '.', diff --git a/packages/protocol/src/scm.ts b/packages/protocol/src/scm.ts index cb6d52713..017f6104a 100644 --- a/packages/protocol/src/scm.ts +++ b/packages/protocol/src/scm.ts @@ -229,6 +229,26 @@ export const ScmChangeApplyResponseSchema = z.object({ }); export type ScmChangeApplyResponse = z.infer<typeof ScmChangeApplyResponseSchema>; +export const ScmChangeDiscardEntrySchema = z.object({ + path: z.string(), + kind: ScmEntryKindSchema, +}); +export type ScmChangeDiscardEntry = z.infer<typeof ScmChangeDiscardEntrySchema>; + +export const ScmChangeDiscardRequestSchema = ScmRequestBaseSchema.extend({ + entries: z.array(ScmChangeDiscardEntrySchema).min(1), +}); +export type ScmChangeDiscardRequest = z.infer<typeof ScmChangeDiscardRequestSchema>; + +export const ScmChangeDiscardResponseSchema = z.object({ + success: z.boolean(), + stdout: z.string().optional(), + stderr: z.string().optional(), + error: z.string().optional(), + errorCode: ScmOperationErrorCodeSchema.optional(), +}); +export type ScmChangeDiscardResponse = z.infer<typeof ScmChangeDiscardResponseSchema>; + export const ScmCommitPatchSchema = z.object({ path: z.string(), patch: z.string().min(1).max(SCM_COMMIT_PATCH_MAX_LENGTH), diff --git a/packages/protocol/src/sentFrom.ts b/packages/protocol/src/sentFrom.ts index 82cbb7db6..ac36bded3 100644 --- a/packages/protocol/src/sentFrom.ts +++ b/packages/protocol/src/sentFrom.ts @@ -8,6 +8,8 @@ export const SENT_FROM_VALUES = [ 'ios', 'mac', 'retry', + 'e2e', + 'voice_agent', ] as const; export type SentFrom = (typeof SENT_FROM_VALUES)[number]; @@ -19,5 +21,8 @@ export type SentFrom = (typeof SENT_FROM_VALUES)[number]; * - Known values parse as-is. * - Unknown/invalid values parse as `'unknown'` (forward compatible; never throws). */ -export const SentFromSchema = z.enum(SENT_FROM_VALUES).catch('unknown'); +export function createSentFromSchema(zod: typeof z) { + return zod.enum(SENT_FROM_VALUES).catch('unknown'); +} +export const SentFromSchema = createSentFromSchema(z); diff --git a/packages/protocol/src/sessionControl/contract.test.ts b/packages/protocol/src/sessionControl/contract.test.ts index ae202ca60..e4ecc0539 100644 --- a/packages/protocol/src/sessionControl/contract.test.ts +++ b/packages/protocol/src/sessionControl/contract.test.ts @@ -112,4 +112,121 @@ describe('sessionControl contract exports', () => { }); expect(parsed.success).toBe(true); }); + + it('validates v2 session list and session-by-id wire responses', () => { + const listSchema = (protocol as any).V2SessionListResponseSchema; + const listParsed = listSchema.safeParse({ + sessions: [ + { + id: 'sess_1', + seq: 10, + createdAt: 1, + updatedAt: 2, + active: true, + activeAt: 3, + archivedAt: null, + encryptionMode: 'plain', + metadata: 'm', + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + pendingCount: 0, + pendingVersion: 1, + dataEncryptionKey: 'a2V5', + share: { accessLevel: 'edit', canApprovePermissions: true }, + }, + ], + nextCursor: null, + hasNext: false, + }); + expect(listParsed.success).toBe(true); + + const invalidModeParsed = listSchema.safeParse({ + sessions: [ + { + id: 'sess_bad', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + archivedAt: null, + encryptionMode: 'nope', + metadata: 'm', + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + dataEncryptionKey: null, + }, + ], + nextCursor: null, + hasNext: false, + }); + expect(invalidModeParsed.success).toBe(false); + + const byIdSchema = (protocol as any).V2SessionByIdResponseSchema; + const byIdParsed = byIdSchema.safeParse({ + session: { + id: 'sess_1', + seq: 10, + createdAt: 1, + updatedAt: 2, + active: true, + activeAt: 3, + metadata: 'm', + metadataVersion: 1, + agentState: 'a', + agentStateVersion: 0, + pendingCount: 0, + dataEncryptionKey: null, + encryptionMode: 'e2ee', + }, + }); + expect(byIdParsed.success).toBe(true); + }); + + it('validates v2 session message responses', () => { + const schema = (protocol as any).V2SessionMessageResponseSchema; + const parsed = schema.safeParse({ + didWrite: true, + message: { + id: 'msg_1', + seq: 12, + localId: null, + createdAt: 1700000000000, + }, + }); + expect(parsed.success).toBe(true); + }); + + it('extracts system-session metadata safely', () => { + const parsed = (protocol as any).readSystemSessionMetadataFromMetadata({ + metadata: { + systemSessionV1: { + v: 1, + key: 'voice_carrier', + hidden: true, + }, + }, + }); + expect(parsed).toEqual({ + v: 1, + key: 'voice_carrier', + hidden: true, + }); + + expect((protocol as any).isHiddenSystemSession({ metadata: null })).toBe(false); + expect((protocol as any).isHiddenSystemSession({ metadata: { systemSessionV1: { v: 1, key: 'carrier' } } })).toBe(false); + expect((protocol as any).isHiddenSystemSession({ metadata: { systemSessionV1: { v: 1, key: 'carrier', hidden: true } } })).toBe(true); + }); + + it('encodes and decodes v2 session list cursors', () => { + const encode = (protocol as any).encodeV2SessionListCursorV1; + const decode = (protocol as any).decodeV2SessionListCursorV1; + + expect(encode('sess_123')).toBe('cursor_v1_sess_123'); + expect(decode('cursor_v1_sess_123')).toBe('sess_123'); + expect(decode('cursor_v1_')).toBe(null); + expect(decode('nope')).toBe(null); + }); }); diff --git a/packages/protocol/src/sessionControl/contract.ts b/packages/protocol/src/sessionControl/contract.ts index 6f69a5cce..0ef7b2187 100644 --- a/packages/protocol/src/sessionControl/contract.ts +++ b/packages/protocol/src/sessionControl/contract.ts @@ -8,18 +8,21 @@ import { import { ActionIdSchema, ActionInputHintsSchema, ActionSafetySchema, ActionSurfaceSchema } from '../actions/index.js'; import { ActionUiPlacementSchema } from '../actions/actionUiPlacements.js'; import { SubAgentRunResultV2Schema } from '../tools/v2/index.js'; +import { AccountEncryptionModeSchema } from '../features/payload/capabilities/encryptionCapabilities.js'; export const SessionControlErrorCodeSchema = z.enum([ 'not_authenticated', 'server_unreachable', 'session_not_found', 'session_id_ambiguous', + 'session_active', 'execution_run_not_found', 'execution_run_action_not_supported', 'execution_run_invalid_action_input', 'execution_run_stream_not_found', 'execution_run_not_allowed', 'run_depth_exceeded', + 'conflict', 'timeout', 'invalid_arguments', 'unsupported', @@ -76,8 +79,10 @@ export const SessionSummarySchema = z.object({ updatedAt: z.number().int().nonnegative(), active: z.boolean(), activeAt: z.number().int().nonnegative(), + archivedAt: z.number().int().nonnegative().nullable().optional(), pendingCount: z.number().int().nonnegative().optional(), tag: z.string().optional(), + title: z.string().min(1).optional(), path: z.string().optional(), host: z.string().optional(), share: z.object({ @@ -86,12 +91,62 @@ export const SessionSummarySchema = z.object({ }).nullable().optional(), isSystem: z.boolean().optional(), systemPurpose: z.string().nullable().optional(), + encryptionMode: AccountEncryptionModeSchema.optional(), encryption: z.object({ type: z.enum(['legacy', 'dataKey']), }).passthrough(), }).passthrough(); export type SessionSummary = z.infer<typeof SessionSummarySchema>; +/** + * Factory form (accepts a caller-provided `z`) for nohoist/multi-zod-instance repos. + * Consumers that need to embed the schema into their own Zod objects should use this + * instead of importing `SessionSystemSessionV1Schema` directly. + */ +export function createSessionSystemSessionV1Schema(zod: typeof z) { + return zod.object({ + v: zod.literal(1), + key: zod.string(), + hidden: zod.boolean().optional(), + }).passthrough(); +} + +export const SessionSystemSessionV1Schema = createSessionSystemSessionV1Schema(z); +export type SessionSystemSessionV1 = z.infer<typeof SessionSystemSessionV1Schema>; + +export function createSessionMetadataSchema(zod: typeof z) { + return zod + .object({ + systemSessionV1: createSessionSystemSessionV1Schema(zod).optional(), + }) + .passthrough(); +} + +export const SessionMetadataSchema = createSessionMetadataSchema(z); +export type SessionMetadata = z.infer<typeof SessionMetadataSchema>; + +export function readSystemSessionMetadataFromMetadata(params: Readonly<{ metadata: unknown }>): SessionSystemSessionV1 | null { + const parsed = SessionMetadataSchema.safeParse(params.metadata); + if (!parsed.success) return null; + return parsed.data.systemSessionV1 ?? null; +} + +export function isHiddenSystemSession(params: Readonly<{ metadata: unknown }>): boolean { + const systemSession = readSystemSessionMetadataFromMetadata(params); + return Boolean(systemSession && systemSession.hidden === true); +} + +export function buildSystemSessionMetadataV1(params: Readonly<{ key: string; hidden?: boolean }>): { systemSessionV1: SessionSystemSessionV1 } { + const hidden = params.hidden; + return { + systemSessionV1: { + v: 1, + key: params.key, + ...(typeof hidden === 'boolean' ? { hidden } : {}), + }, + }; +} + export const SessionListResultSchema = z.object({ sessions: z.array(SessionSummarySchema), nextCursor: z.string().nullable().optional(), @@ -99,6 +154,85 @@ export const SessionListResultSchema = z.object({ }).passthrough(); export type SessionListResult = z.infer<typeof SessionListResultSchema>; +export const SessionShareSchema = z + .object({ + accessLevel: z.enum(['view', 'edit', 'admin']), + canApprovePermissions: z.boolean(), + }) + .passthrough(); +export type SessionShare = z.infer<typeof SessionShareSchema>; + +export const V2SessionRecordSchema = z + .object({ + id: z.string().min(1), + seq: z.number().int().nonnegative(), + createdAt: z.number().int().nonnegative(), + updatedAt: z.number().int().nonnegative(), + active: z.boolean(), + activeAt: z.number().int().nonnegative(), + archivedAt: z.number().int().nonnegative().nullable().optional(), + encryptionMode: AccountEncryptionModeSchema.optional(), + metadata: z.string(), + metadataVersion: z.number().int().nonnegative(), + agentState: z.string().nullable(), + agentStateVersion: z.number().int().nonnegative(), + pendingCount: z.number().int().min(0).optional(), + pendingVersion: z.number().int().min(0).optional(), + dataEncryptionKey: z.string().nullable(), + share: SessionShareSchema.nullable().optional(), + }) + .passthrough(); +export type V2SessionRecord = z.infer<typeof V2SessionRecordSchema>; + +export const V2SessionListResponseSchema = z + .object({ + sessions: z.array(V2SessionRecordSchema), + nextCursor: z.string().nullable().optional(), + hasNext: z.boolean().optional(), + }) + .passthrough(); +export type V2SessionListResponse = z.infer<typeof V2SessionListResponseSchema>; + +export const V2_SESSION_LIST_CURSOR_V1_PREFIX = 'cursor_v1_' as const; + +export function encodeV2SessionListCursorV1(sessionId: string): string { + return `${V2_SESSION_LIST_CURSOR_V1_PREFIX}${sessionId}`; +} + +export function decodeV2SessionListCursorV1(cursor: string): string | null { + if (typeof cursor !== 'string') return null; + if (!cursor.startsWith(V2_SESSION_LIST_CURSOR_V1_PREFIX)) return null; + const sessionId = cursor.slice(V2_SESSION_LIST_CURSOR_V1_PREFIX.length); + return sessionId.length > 0 ? sessionId : null; +} + +export const V2SessionByIdResponseSchema = z + .object({ + session: V2SessionRecordSchema, + }) + .passthrough(); +export type V2SessionByIdResponse = z.infer<typeof V2SessionByIdResponseSchema>; + +export const V2SessionByIdNotFoundSchema = z.object({ + error: z.literal('Session not found'), +}); +export type V2SessionByIdNotFound = z.infer<typeof V2SessionByIdNotFoundSchema>; + +export const V2SessionMessageResponseSchema = z + .object({ + didWrite: z.boolean(), + message: z + .object({ + id: z.string().min(1), + seq: z.number().int().nonnegative(), + localId: z.string().nullable(), + createdAt: z.number().int().nonnegative(), + }) + .passthrough(), + }) + .passthrough(); +export type V2SessionMessageResponse = z.infer<typeof V2SessionMessageResponseSchema>; + export const SessionStatusResultSchema = z.object({ session: SessionSummarySchema, agentState: z.object({ @@ -134,6 +268,38 @@ export const SessionStopResultSchema = z.object({ }).passthrough(); export type SessionStopResult = z.infer<typeof SessionStopResultSchema>; +export const SessionArchiveResultSchema = z.object({ + sessionId: z.string().min(1), + archivedAt: z.number().int().nonnegative(), +}).passthrough(); +export type SessionArchiveResult = z.infer<typeof SessionArchiveResultSchema>; + +export const SessionUnarchiveResultSchema = z.object({ + sessionId: z.string().min(1), + archivedAt: z.null(), +}).passthrough(); +export type SessionUnarchiveResult = z.infer<typeof SessionUnarchiveResultSchema>; + +export const SessionSetTitleResultSchema = z.object({ + sessionId: z.string().min(1), + title: z.string().min(1), +}).passthrough(); +export type SessionSetTitleResult = z.infer<typeof SessionSetTitleResultSchema>; + +export const SessionSetPermissionModeResultSchema = z.object({ + sessionId: z.string().min(1), + permissionMode: z.string().min(1), + updatedAt: z.number().int().nonnegative(), +}).passthrough(); +export type SessionSetPermissionModeResult = z.infer<typeof SessionSetPermissionModeResultSchema>; + +export const SessionSetModelResultSchema = z.object({ + sessionId: z.string().min(1), + modelId: z.string().min(1), + updatedAt: z.number().int().nonnegative(), +}).passthrough(); +export type SessionSetModelResult = z.infer<typeof SessionSetModelResultSchema>; + export const SessionHistoryCompactMessageSchema = z.object({ id: z.string().min(1), createdAt: z.number().int().nonnegative(), @@ -286,6 +452,31 @@ export const SessionStopEnvelopeSchema = SessionControlEnvelopeSuccessSchema.ext data: SessionStopResultSchema, }); +export const SessionArchiveEnvelopeSchema = SessionControlEnvelopeSuccessSchema.extend({ + kind: z.literal('session_archive'), + data: SessionArchiveResultSchema, +}); + +export const SessionUnarchiveEnvelopeSchema = SessionControlEnvelopeSuccessSchema.extend({ + kind: z.literal('session_unarchive'), + data: SessionUnarchiveResultSchema, +}); + +export const SessionSetTitleEnvelopeSchema = SessionControlEnvelopeSuccessSchema.extend({ + kind: z.literal('session_set_title'), + data: SessionSetTitleResultSchema, +}); + +export const SessionSetPermissionModeEnvelopeSchema = SessionControlEnvelopeSuccessSchema.extend({ + kind: z.literal('session_set_permission_mode'), + data: SessionSetPermissionModeResultSchema, +}); + +export const SessionSetModelEnvelopeSchema = SessionControlEnvelopeSuccessSchema.extend({ + kind: z.literal('session_set_model'), + data: SessionSetModelResultSchema, +}); + export const SessionRunStartEnvelopeSchema = SessionControlEnvelopeSuccessSchema.extend({ kind: z.literal('session_run_start'), data: SessionRunStartResultSchema, diff --git a/packages/protocol/src/sessionMessages/sessionMessageMeta.test.ts b/packages/protocol/src/sessionMessages/sessionMessageMeta.test.ts new file mode 100644 index 000000000..70369127e --- /dev/null +++ b/packages/protocol/src/sessionMessages/sessionMessageMeta.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import * as protocol from '../index.js'; + +describe('sessionMessages meta', () => { + it('parses unknown sentFrom/permissionMode without throwing', () => { + const parsed = (protocol as any).SessionMessageMetaSchema.parse({ + source: 'cli', + sentFrom: '__future__', + permissionMode: '__future__', + extra: 'x', + }); + + expect(parsed.sentFrom).toBe('unknown'); + expect(parsed.permissionMode).toBe('default'); + expect((parsed as any).extra).toBe('x'); + }); +}); + diff --git a/packages/protocol/src/sessionMessages/sessionMessageMeta.ts b/packages/protocol/src/sessionMessages/sessionMessageMeta.ts new file mode 100644 index 000000000..4e19ecbba --- /dev/null +++ b/packages/protocol/src/sessionMessages/sessionMessageMeta.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { createSentFromSchema } from '../sentFrom.js'; +import { createSessionPermissionModeSchema } from '../sessionMetadata/sessionPermissionModes.js'; + +/** + * Message-level metadata (stored in encrypted message bodies). + * + * Forward compatibility is critical here: older clients must not fail to parse + * messages when new fields or new enum values are introduced. + */ +export function createSessionMessageMetaSchema(zod: typeof z) { + return zod + .object({ + sentFrom: createSentFromSchema(zod).optional(), + /** + * High-level origin of the message, used by agents to avoid treating + * self-sent client traffic as a "new prompt" event. + * + * Forward-compatible: unknown strings are allowed. + */ + source: zod.union([zod.enum(['ui', 'cli']), zod.string()]).optional(), + permissionMode: createSessionPermissionModeSchema(zod).optional(), + model: zod.string().nullable().optional(), + fallbackModel: zod.string().nullable().optional(), + customSystemPrompt: zod.string().nullable().optional(), + appendSystemPrompt: zod.string().nullable().optional(), + allowedTools: zod.array(zod.string()).nullable().optional(), + disallowedTools: zod.array(zod.string()).nullable().optional(), + displayText: zod.string().optional(), + happier: zod + .object({ + kind: zod.string(), + payload: zod.unknown(), + }) + .optional(), + }) + .passthrough(); +} + +export const SessionMessageMetaSchema = createSessionMessageMetaSchema(z); +export type SessionMessageMeta = z.infer<typeof SessionMessageMetaSchema>; diff --git a/packages/protocol/src/sessionMessages/sessionStoredMessageContent.test.ts b/packages/protocol/src/sessionMessages/sessionStoredMessageContent.test.ts new file mode 100644 index 000000000..b5a00022d --- /dev/null +++ b/packages/protocol/src/sessionMessages/sessionStoredMessageContent.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { SessionStoredMessageContentSchema } from './sessionStoredMessageContent.js'; + +describe('SessionStoredMessageContentSchema', () => { + it('accepts encrypted envelope', () => { + const parsed = SessionStoredMessageContentSchema.safeParse({ t: 'encrypted', c: 'aGVsbG8=' }); + expect(parsed.success).toBe(true); + }); + + it('accepts plain envelope', () => { + const parsed = SessionStoredMessageContentSchema.safeParse({ t: 'plain', v: { type: 'user', text: 'hi' } }); + expect(parsed.success).toBe(true); + }); + + it('rejects unknown envelope', () => { + const parsed = SessionStoredMessageContentSchema.safeParse({ t: 'nope', c: 'x' }); + expect(parsed.success).toBe(false); + }); +}); + diff --git a/packages/protocol/src/sessionMessages/sessionStoredMessageContent.ts b/packages/protocol/src/sessionMessages/sessionStoredMessageContent.ts new file mode 100644 index 000000000..f74587ccf --- /dev/null +++ b/packages/protocol/src/sessionMessages/sessionStoredMessageContent.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const SessionStoredMessageContentSchema = z.discriminatedUnion('t', [ + z.object({ + t: z.literal('encrypted'), + c: z.string().min(1), + }), + z.object({ + t: z.literal('plain'), + v: z.unknown(), + }), +]); + +export type SessionStoredMessageContent = z.infer<typeof SessionStoredMessageContentSchema>; + diff --git a/packages/protocol/src/sessionMetadata/metadataOverridesV1.test.ts b/packages/protocol/src/sessionMetadata/metadataOverridesV1.test.ts new file mode 100644 index 000000000..0f08ce27a --- /dev/null +++ b/packages/protocol/src/sessionMetadata/metadataOverridesV1.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import * as protocol from '../index.js'; + +describe('sessionMetadata overrides v1', () => { + it('builds and parses modelOverrideV1', () => { + const built = (protocol as any).buildModelOverrideV1({ updatedAt: 1, modelId: 'gpt-x' }); + expect(built).toMatchObject({ v: 1, updatedAt: 1, modelId: 'gpt-x' }); + const parsed = (protocol as any).ModelOverrideV1Schema.parse({ ...built, extra: 'x' }); + expect((parsed as any).extra).toBe('x'); + }); + + it('builds and parses acpSessionModeOverrideV1', () => { + const built = (protocol as any).buildAcpSessionModeOverrideV1({ updatedAt: 2, modeId: 'plan' }); + expect(built).toMatchObject({ v: 1, updatedAt: 2, modeId: 'plan' }); + const parsed = (protocol as any).AcpSessionModeOverrideV1Schema.parse({ ...built, extra: 'x' }); + expect((parsed as any).extra).toBe('x'); + }); + + it('builds and parses acpConfigOptionOverridesV1', () => { + const built = (protocol as any).buildAcpConfigOptionOverridesV1({ + updatedAt: 3, + overrides: { + opt_a: { updatedAt: 10, value: 'x' }, + opt_b: { updatedAt: 11, value: null }, + opt_c: { updatedAt: 12, value: 1 }, + opt_d: { updatedAt: 13, value: false }, + }, + }); + expect(built).toMatchObject({ v: 1, updatedAt: 3 }); + const parsed = (protocol as any).AcpConfigOptionOverridesV1Schema.parse({ ...built, extra: 'x' }); + expect((parsed as any).extra).toBe('x'); + expect(Object.keys((parsed as any).overrides ?? {})).toEqual(['opt_a', 'opt_b', 'opt_c', 'opt_d']); + }); +}); + diff --git a/packages/protocol/src/sessionMetadata/metadataOverridesV1.ts b/packages/protocol/src/sessionMetadata/metadataOverridesV1.ts new file mode 100644 index 000000000..e95620645 --- /dev/null +++ b/packages/protocol/src/sessionMetadata/metadataOverridesV1.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; + +/** + * Session metadata override payloads (V1). + * + * These are stored inside encrypted `session.metadata` and are shared across UI/CLI. + * Keep schemas permissive (passthrough) for forward compatibility. + * + * NOTE: Use the `create*Schema` factory forms for repos that may have multiple Zod + * instances (nohoist); callers should pass their local `z` import. + */ + +export function createModelOverrideV1Schema(zod: typeof z) { + return zod + .object({ + v: zod.literal(1), + updatedAt: zod.number().finite(), + modelId: zod.string(), + }) + .passthrough(); +} + +export const ModelOverrideV1Schema = createModelOverrideV1Schema(z); +export type ModelOverrideV1 = z.infer<typeof ModelOverrideV1Schema>; + +export function buildModelOverrideV1(params: Readonly<{ updatedAt: number; modelId: string }>): ModelOverrideV1 { + return { + v: 1, + updatedAt: params.updatedAt, + modelId: params.modelId, + }; +} + +export function createAcpSessionModeOverrideV1Schema(zod: typeof z) { + return zod + .object({ + v: zod.literal(1), + updatedAt: zod.number().finite(), + modeId: zod.string(), + }) + .passthrough(); +} + +export const AcpSessionModeOverrideV1Schema = createAcpSessionModeOverrideV1Schema(z); +export type AcpSessionModeOverrideV1 = z.infer<typeof AcpSessionModeOverrideV1Schema>; + +export function buildAcpSessionModeOverrideV1(params: Readonly<{ updatedAt: number; modeId: string }>): AcpSessionModeOverrideV1 { + return { + v: 1, + updatedAt: params.updatedAt, + modeId: params.modeId, + }; +} + +export function createAcpConfigOptionOverridesV1Schema(zod: typeof z) { + const valueSchema = zod.union([zod.string(), zod.number(), zod.boolean(), zod.null()]); + return zod + .object({ + v: zod.literal(1), + updatedAt: zod.number().finite(), + overrides: zod.record( + zod.string(), + zod + .object({ + updatedAt: zod.number().finite(), + value: valueSchema, + }) + .passthrough(), + ), + }) + .passthrough(); +} + +export const AcpConfigOptionOverridesV1Schema = createAcpConfigOptionOverridesV1Schema(z); +export type AcpConfigOptionOverridesV1 = z.infer<typeof AcpConfigOptionOverridesV1Schema>; + +export function buildAcpConfigOptionOverridesV1(params: Readonly<{ + updatedAt: number; + overrides: Record<string, { updatedAt: number; value: string | number | boolean | null }>; +}>): AcpConfigOptionOverridesV1 { + return { + v: 1, + updatedAt: params.updatedAt, + overrides: params.overrides, + }; +} + diff --git a/packages/protocol/src/sessionMetadata/sessionPermissionModes.ts b/packages/protocol/src/sessionMetadata/sessionPermissionModes.ts new file mode 100644 index 000000000..848c7dd22 --- /dev/null +++ b/packages/protocol/src/sessionMetadata/sessionPermissionModes.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const SESSION_PERMISSION_MODES = [ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'read-only', + 'safe-yolo', + 'yolo', +] as const; + +export type SessionPermissionMode = (typeof SESSION_PERMISSION_MODES)[number]; + +/** + * Parse behavior: + * - Known values parse as-is. + * - Unknown/invalid values parse as `'default'` (forward compatible; never throws). + */ +export function createSessionPermissionModeSchema(zod: typeof z) { + return zod.enum(SESSION_PERMISSION_MODES).catch('default'); +} + +export const SessionPermissionModeSchema = createSessionPermissionModeSchema(z); + diff --git a/packages/protocol/src/sessionMetadata/terminalMetadata.test.ts b/packages/protocol/src/sessionMetadata/terminalMetadata.test.ts new file mode 100644 index 000000000..8059ed211 --- /dev/null +++ b/packages/protocol/src/sessionMetadata/terminalMetadata.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import * as protocol from '../index.js'; + +describe('sessionMetadata terminal metadata', () => { + it('parses tmux terminal metadata and preserves unknown fields', () => { + const parsed = (protocol as any).SessionTerminalMetadataSchema.parse({ + mode: 'tmux', + requested: 'tmux', + tmux: { target: 'happy:win-1', tmpDir: '/tmp/x' }, + extra: 'x', + }); + expect(parsed.mode).toBe('tmux'); + expect((parsed as any).extra).toBe('x'); + }); + + it('accepts tmux.tmpDir=null for backward compatibility', () => { + const parsed = (protocol as any).SessionTerminalMetadataSchema.parse({ + mode: 'tmux', + tmux: { target: 'happy:win-1', tmpDir: null }, + }); + expect(parsed.mode).toBe('tmux'); + expect((parsed as any).tmux?.tmpDir).toBe(null); + }); +}); diff --git a/packages/protocol/src/sessionMetadata/terminalMetadata.ts b/packages/protocol/src/sessionMetadata/terminalMetadata.ts new file mode 100644 index 000000000..cf5735139 --- /dev/null +++ b/packages/protocol/src/sessionMetadata/terminalMetadata.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +/** + * Session terminal attachment metadata (stored in encrypted `session.metadata`). + * + * Keep schemas permissive (passthrough) for forward compatibility. + * Use factory forms for nohoist/multi-Zod repos. + */ + +export function createSessionTerminalMetadataSchema(zod: typeof z) { + return zod + .object({ + mode: zod.enum(['plain', 'tmux']), + requested: zod.enum(['plain', 'tmux']).optional(), + fallbackReason: zod.string().optional(), + tmux: zod + .object({ + target: zod.string(), + tmpDir: zod.string().nullable().optional(), + }) + .optional(), + }) + .passthrough(); +} + +export const SessionTerminalMetadataSchema = createSessionTerminalMetadataSchema(z); +export type SessionTerminalMetadata = z.infer<typeof SessionTerminalMetadataSchema>; diff --git a/packages/protocol/src/social/friends.ts b/packages/protocol/src/social/friends.ts index 3aa2ecb04..2f49fcf1f 100644 --- a/packages/protocol/src/social/friends.ts +++ b/packages/protocol/src/social/friends.ts @@ -14,7 +14,8 @@ export const UserProfileSchema = z.object({ bio: z.string().nullable(), badges: z.array(ProfileBadgeSchema).optional().default([]), status: RelationshipStatusSchema, - publicKey: z.string(), + // Keyless accounts (enterprise/plaintext mode) may not have an E2EE signing public key. + publicKey: z.string().nullable(), // Optional for backward compatibility with older servers. contentPublicKey: z.string().nullable().optional(), contentPublicKeySig: z.string().nullable().optional(), diff --git a/packages/protocol/src/social/friends.userProfileSchema.test.ts b/packages/protocol/src/social/friends.userProfileSchema.test.ts new file mode 100644 index 000000000..6651fcf84 --- /dev/null +++ b/packages/protocol/src/social/friends.userProfileSchema.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { UserProfileSchema } from './friends.js'; + +describe('UserProfileSchema', () => { + it('accepts keyless accounts (publicKey=null)', () => { + const parsed = UserProfileSchema.parse({ + id: 'u_1', + firstName: 'A', + lastName: null, + avatar: null, + username: 'alice', + bio: null, + badges: [], + status: 'none', + publicKey: null, + contentPublicKey: null, + contentPublicKeySig: null, + }); + + expect(parsed.publicKey).toBeNull(); + }); +}); + diff --git a/packages/protocol/src/updates.sharing.test.ts b/packages/protocol/src/updates.sharing.test.ts new file mode 100644 index 000000000..0e893eed4 --- /dev/null +++ b/packages/protocol/src/updates.sharing.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { UpdateBodySchema } from './updates.js'; + +describe('updates sharing', () => { + it('accepts session-shared updates without encryptedDataKey', () => { + const parsed = UpdateBodySchema.safeParse({ + t: 'session-shared', + sessionId: 'sess_1', + shareId: 'share_1', + sharedBy: { id: 'u1', firstName: null, lastName: null, username: null, avatar: null }, + accessLevel: 'view', + createdAt: Date.now(), + }); + expect(parsed.success).toBe(true); + }); +}); + diff --git a/packages/protocol/src/updates.ts b/packages/protocol/src/updates.ts index c4bd87cc7..83145f1ce 100644 --- a/packages/protocol/src/updates.ts +++ b/packages/protocol/src/updates.ts @@ -170,7 +170,7 @@ export const UpdateBodySchema = z.discriminatedUnion('t', [ avatar: z.unknown().nullable(), }).passthrough(), accessLevel: z.enum(['view', 'edit', 'admin']), - encryptedDataKey: Base64Schema, + encryptedDataKey: Base64Schema.optional(), createdAt: TimestampMsSchema, }).passthrough(), z.object({ diff --git a/packages/tests/README.md b/packages/tests/README.md index 267db9f7a..a772d97fd 100644 --- a/packages/tests/README.md +++ b/packages/tests/README.md @@ -22,6 +22,7 @@ So these tests intentionally run **real components** (server-light, DB, sockets, - Core deterministic e2e: `yarn workspace @happier-dev/tests test` - Core deterministic e2e (fast lane): `yarn workspace @happier-dev/tests test:core:fast` - Core deterministic e2e (slow lane): `yarn workspace @happier-dev/tests test:core:slow` +- UI E2E (Playwright, web UI): `yarn workspace @happier-dev/tests test:ui:e2e` - Stress (seeded chaos): `yarn workspace @happier-dev/tests test:stress` - Providers (real provider CLIs, opt-in): `yarn workspace @happier-dev/tests test:providers` - Typecheck: `yarn workspace @happier-dev/tests typecheck` @@ -56,6 +57,7 @@ Baseline updates are explicit: ## Suites - `suites/core-e2e/*`: release-gate candidates (fast + slow split) +- `suites/ui-e2e/*`: Playwright-driven browser E2E against Expo web (covers critical UI flows like auth + terminal connect) - `suites/stress/*`: nightly/on-demand (repeat + chaos + flake classification) - `suites/providers/*`: opt-in “real provider contract” tests (slow, may consume provider credits) @@ -77,6 +79,10 @@ Artifacts are written on failure by default. You can force keeping artifacts eve - `HAPPIER_E2E_SAVE_ARTIFACTS=1 yarn workspace @happier-dev/tests test` +UI E2E (Playwright) notes: +- Expo web is started via `expo start --web`; if you suspect stale Metro transforms, you can opt into cache clearing with `HAPPIER_E2E_EXPO_CLEAR=1` (default is off because `--clear` can occasionally crash Metro). +- UI E2E artifacts live under `.project/logs/e2e/ui-playwright/...` and include screenshots + videos on failure. + ## Core e2e suite: what each test ensures These tests always boot a real local server (local files backend) and use real sockets/HTTP. diff --git a/packages/tests/package.json b/packages/tests/package.json index d94ca5f40..ff03e02ed 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -9,6 +9,7 @@ "test:core:slow": "node scripts/run-vitest-with-heartbeat.mjs --config vitest.core.slow.config.ts", "test:stress": "node scripts/run-vitest-with-heartbeat.mjs --config vitest.stress.config.ts", "test:providers": "node scripts/run-vitest-with-heartbeat.mjs --config vitest.providers.config.ts", + "test:ui:e2e": "playwright test -c playwright.ui.config.mjs", "typecheck": "tsc --noEmit", "providers:run": "node scripts/run-providers.mjs", "providers:run:parallel": "node scripts/run-providers-parallel.mjs", @@ -52,6 +53,7 @@ "privacy-kit": "^0.0.25" }, "devDependencies": { + "@playwright/test": "^1.56.0", "@types/node": ">=20", "typescript": "5.9.3", "vitest": "^3.2.4" diff --git a/packages/tests/playwright.ui.config.mjs b/packages/tests/playwright.ui.config.mjs new file mode 100644 index 000000000..d7fb5d838 --- /dev/null +++ b/packages/tests/playwright.ui.config.mjs @@ -0,0 +1,23 @@ +// @ts-check +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: 'suites/ui-e2e', + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list'], ['html']], + outputDir: '.project/logs/e2e/ui-playwright', + use: { + testIdAttribute: 'data-testid', + // Keep UI e2e deterministic by avoiding responsive split-view layouts. + // A phone-sized viewport ensures a single primary navigation stack on Expo web. + viewport: { width: 390, height: 844 }, + actionTimeout: 15_000, + navigationTimeout: 90_000, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, +}); diff --git a/packages/tests/scripts/extended-db-docker.plan.mjs b/packages/tests/scripts/extended-db-docker.plan.mjs index 491724673..1efce4507 100644 --- a/packages/tests/scripts/extended-db-docker.plan.mjs +++ b/packages/tests/scripts/extended-db-docker.plan.mjs @@ -18,6 +18,18 @@ function resolveServerAppWorkspaceName() { } } +function resolveCliAppWorkspaceName() { + try { + const pkgPath = resolve(REPO_ROOT, 'apps', 'cli', 'package.json'); + const raw = readFileSync(pkgPath, 'utf8'); + const json = JSON.parse(raw); + const name = typeof json?.name === 'string' ? json.name.trim() : ''; + return name || '@happier-dev/cli'; + } catch { + return '@happier-dev/cli'; + } +} + export function sanitizeDockerEnv(env) { const out = { ...(env ?? {}) }; // Allow docker client to negotiate with the daemon. On some machines, DOCKER_API_VERSION is pinned @@ -105,6 +117,20 @@ export function buildExtendedDbCommandPlan({ db, mode, databaseUrl }) { /** @type {Array<{kind: string, command: string, args: string[], env: Record<string, string>}>} */ const steps = []; + const prebuildCliSharedStep = { + kind: 'prebuild-cli-shared', + command: 'yarn', + args: ['-s', 'workspace', resolveCliAppWorkspaceName(), 'build:shared'], + env: { CI: '1' }, + }; + + const prebuildCliStep = { + kind: 'prebuild-cli', + command: 'yarn', + args: ['-s', 'workspace', resolveCliAppWorkspaceName(), 'build'], + env: { CI: '1' }, + }; + const e2eStep = { kind: 'e2e', command: 'yarn', @@ -140,7 +166,7 @@ export function buildExtendedDbCommandPlan({ db, mode, databaseUrl }) { }, }; - if (mode === 'e2e') return [e2eStep]; + if (mode === 'e2e') return [prebuildCliSharedStep, prebuildCliStep, e2eStep]; if (mode === 'contract') return [migrateStep, contractStep]; - return [e2eStep, migrateStep, contractStep]; + return [prebuildCliSharedStep, prebuildCliStep, e2eStep, migrateStep, contractStep]; } diff --git a/packages/tests/scripts/run-extended-db-docker.d.mts b/packages/tests/scripts/run-extended-db-docker.d.mts index c4965db7f..e8d0bcd55 100644 --- a/packages/tests/scripts/run-extended-db-docker.d.mts +++ b/packages/tests/scripts/run-extended-db-docker.d.mts @@ -11,4 +11,5 @@ export interface ExtendedDbArgs { export function parseArgs(argv: string[]): ExtendedDbArgs; export function resolveExtendedDbCommandTimeoutMs(raw: unknown, fallbackMs: number): number; +export function resolveExtendedDbStepTimeoutMs(env: NodeJS.ProcessEnv): number; export function main(argv?: string[]): Promise<number>; diff --git a/packages/tests/scripts/run-extended-db-docker.mjs b/packages/tests/scripts/run-extended-db-docker.mjs index f4aa6f48c..b74a5d273 100644 --- a/packages/tests/scripts/run-extended-db-docker.mjs +++ b/packages/tests/scripts/run-extended-db-docker.mjs @@ -34,6 +34,10 @@ export function resolveExtendedDbCommandTimeoutMs(raw, fallbackMs) { return Math.max(5_000, parsed); } +export function resolveExtendedDbStepTimeoutMs(env) { + return resolveExtendedDbCommandTimeoutMs(env?.HAPPIER_E2E_EXTENDED_DB_STEP_TIMEOUT_MS, 3_600_000); +} + function formatCommand(cmd, args) { return `${cmd} ${args.join(' ')}`.trim(); } @@ -136,10 +140,7 @@ export async function main(argv = process.argv) { process.env.HAPPIER_E2E_EXTENDED_DB_DOCKER_TIMEOUT_MS, 120_000, ); - const testStepTimeoutMs = resolveExtendedDbCommandTimeoutMs( - process.env.HAPPIER_E2E_EXTENDED_DB_STEP_TIMEOUT_MS, - 2_400_000, - ); + const testStepTimeoutMs = resolveExtendedDbStepTimeoutMs(process.env); const db = parsed.db; const mode = parsed.mode ?? 'extended'; diff --git a/packages/tests/src/fixtures/fake-claude-code-cli.cjs b/packages/tests/src/fixtures/fake-claude-code-cli.cjs index 0fb0da28c..3210cff83 100644 --- a/packages/tests/src/fixtures/fake-claude-code-cli.cjs +++ b/packages/tests/src/fixtures/fake-claude-code-cli.cjs @@ -45,6 +45,39 @@ const hasPrint = argv.includes('--print'); const mode = isSdkStreamJson ? 'sdk' : 'local'; const scenario = process.env.HAPPIER_E2E_FAKE_CLAUDE_SCENARIO || process.env.HAPPY_E2E_FAKE_CLAUDE_SCENARIO || ''; +function extractUserTextFromSdkMessage(msg) { + if (!msg || typeof msg !== 'object') return null; + const message = msg.message; + if (!message || typeof message !== 'object') return null; + if (message.role !== 'user') return null; + + const content = message.content; + if (typeof content === 'string') { + const trimmed = content.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + if (Array.isArray(content)) { + const parts = []; + for (const part of content) { + if (typeof part === 'string') { + const trimmed = part.trim(); + if (trimmed) parts.push(trimmed); + continue; + } + if (!part || typeof part !== 'object') continue; + if (part.type === 'text' && typeof part.text === 'string') { + const trimmed = part.text.trim(); + if (trimmed) parts.push(trimmed); + } + } + const joined = parts.join('\n').trim(); + return joined.length > 0 ? joined : null; + } + + return null; +} + safeAppendJsonl(logPath, { type: 'invocation', invocationId, @@ -74,14 +107,100 @@ async function runSdkStreamUntilEof() { let initialized = false; let turn = 0; - const systemInit = { - type: 'system', - subtype: 'init', - session_id: sessionId, - cwd: process.cwd(), - tools: ['Bash(echo)'], - slash_commands: ['/help'], - }; + function emitSdk(obj) { + process.stdout.write(`${JSON.stringify(obj)}\n`); + safeAppendJsonl(logPath, { + type: 'sdk_stdout', + invocationId, + ts: Date.now(), + messageType: obj?.type ?? null, + messageSubtype: obj?.subtype ?? null, + }); + } + + function createControlResponse(requestId, response) { + return { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + ...(response ? { response } : {}), + }, + }; + } + + function createSystemInitMessage() { + const mcpServers = Object.keys(mergedMcpServers || {}).map((name) => ({ + name, + status: 'connected', + })); + + return { + type: 'system', + subtype: 'init', + apiKeySource: 'project', + claude_code_version: '0.0.0-fake', + cwd: process.cwd(), + tools: ['Bash(echo)'], + mcp_servers: mcpServers, + model: 'fake-claude', + permissionMode: 'default', + slash_commands: ['/help'], + output_style: 'default', + skills: [], + plugins: [], + uuid: randomUUID(), + session_id: sessionId, + }; + } + + function createAssistantMessage(content) { + return { + type: 'assistant', + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: sessionId, + message: { + id: `fake-assistant-${turn}`, + type: 'message', + role: 'assistant', + model: 'fake-claude', + content, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + } + + function createUserMessage(content) { + return { + type: 'user', + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: sessionId, + message: { role: 'user', content }, + }; + } + + function createResultSuccess() { + return { + type: 'result', + subtype: 'success', + result: `FAKE_CLAUDE_DONE_${turn}`, + num_turns: turn, + usage: { input_tokens: 1, output_tokens: 1 }, + modelUsage: {}, + permission_denials: [], + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + stop_reason: null, + uuid: randomUUID(), + session_id: sessionId, + }; + } for await (const line of rl) { const trimmed = String(line || '').trim(); @@ -92,74 +211,75 @@ async function runSdkStreamUntilEof() { } catch { continue; } - if (!msg || msg.type !== 'user') continue; + + // Respond to Agent SDK control requests (initialize, set_permission_mode, etc). + if (msg && typeof msg === 'object' && msg.type === 'control_request') { + const requestId = typeof msg.request_id === 'string' ? msg.request_id : null; + safeAppendJsonl(logPath, { + type: 'sdk_stdin', + invocationId, + ts: Date.now(), + messageType: msg?.type ?? null, + controlSubtype: msg?.request?.subtype ?? null, + requestId, + hasUserText: false, + }); + if (requestId) { + emitSdk(createControlResponse(requestId)); + } + continue; + } + + const promptText = extractUserTextFromSdkMessage(msg); + safeAppendJsonl(logPath, { + type: 'sdk_stdin', + invocationId, + ts: Date.now(), + messageType: msg?.type ?? null, + messageRole: msg?.message?.role ?? null, + hasUserText: Boolean(promptText), + }); + if (!promptText) continue; if (!initialized) { initialized = true; - process.stdout.write(`${JSON.stringify(systemInit)}\n`); + emitSdk(createSystemInitMessage()); } const now = Date.now(); turn += 1; if (scenario === 'memory-hints-json') { - const parts = Array.isArray(msg?.message?.content) ? msg.message.content : []; - const promptText = parts - .map((p) => { - if (typeof p === 'string') return p; - if (p && typeof p === 'object' && p.type === 'text' && typeof p.text === 'string') return p.text; - return ''; - }) - .join('\n'); const match = String(promptText).match(/OPENCLAW_MEMORY_SENTINEL_[A-Za-z0-9_-]+/); const sentinel = match ? match[0] : `FAKE_MEMORY_SENTINEL_${turn}`; - const assistant = { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'text', - text: JSON.stringify({ - shard: { - v: 1, - seqFrom: 0, - seqTo: 0, - createdAtFromMs: 0, - createdAtToMs: 0, - summary: `Summary shard for ${sentinel}`, - keywords: ['openclaw', sentinel], - entities: [], - decisions: [], - }, - synopsis: { - v: 1, - seqTo: 0, - updatedAtMs: now, - synopsis: `Session synopsis including ${sentinel}`, - }, - }), + const assistant = createAssistantMessage([ + { + type: 'text', + text: JSON.stringify({ + shard: { + v: 1, + seqFrom: 0, + seqTo: 0, + createdAtFromMs: 0, + createdAtToMs: 0, + summary: `Summary shard for ${sentinel}`, + keywords: ['openclaw', sentinel], + entities: [], + decisions: [], }, - ], + synopsis: { + v: 1, + seqTo: 0, + updatedAtMs: now, + synopsis: `Session synopsis including ${sentinel}`, + }, + }), }, - }; + ]); - const result = { - type: 'result', - subtype: 'success', - result: `FAKE_CLAUDE_DONE_${turn}`, - num_turns: turn, - usage: { input_tokens: 1, output_tokens: 1 }, - total_cost_usd: 0, - duration_ms: Math.max(1, Date.now() - now), - duration_api_ms: 1, - is_error: false, - session_id: sessionId, - }; - - process.stdout.write(`${JSON.stringify(assistant)}\n`); - process.stdout.write(`${JSON.stringify(result)}\n`); + emitSdk(assistant); + emitSdk(createResultSuccess()); continue; } @@ -168,40 +288,29 @@ async function runSdkStreamUntilEof() { const taskToolUseId = `tool_task_${turn}`; const taskOutputToolUseId = `tool_taskoutput_${turn}`; - const assistant = { - type: 'assistant', - parent_tool_use_id: null, - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - id: taskToolUseId, - name: 'Task', - input: { - description: `fake task ${turn}`, - prompt: `do side work ${turn}`, - subagent_type: 'general', - run_in_background: true, - }, - }, - { - type: 'tool_use', - id: taskOutputToolUseId, - name: 'TaskOutput', - input: { task_id: agentId, block: true, timeout: 2000 }, - }, - ], + const assistant = createAssistantMessage([ + { + type: 'tool_use', + id: taskToolUseId, + name: 'Task', + input: { + description: `fake task ${turn}`, + prompt: `do side work ${turn}`, + subagent_type: 'general', + run_in_background: true, + }, }, - }; - - const taskToolResult = { - type: 'user', - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: taskToolUseId, content: `agentId: ${agentId}` }], + { + type: 'tool_use', + id: taskOutputToolUseId, + name: 'TaskOutput', + input: { task_id: agentId, block: true, timeout: 2000 }, }, - }; + ]); + + const taskToolResult = createUserMessage([ + { type: 'tool_result', tool_use_id: taskToolUseId, content: `agentId: ${agentId}` }, + ]); const jsonl = [ // Prompt root (string content) should be filtered out by Happier to avoid duplicate synthetic roots. @@ -238,208 +347,102 @@ async function runSdkStreamUntilEof() { .join('\n') .concat('\n'); - const taskOutputToolResult = { - type: 'user', - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: taskOutputToolUseId, content: jsonl }], - }, - }; + const taskOutputToolResult = createUserMessage([ + { type: 'tool_result', tool_use_id: taskOutputToolUseId, content: jsonl }, + ]); - const result = { - type: 'result', - subtype: 'success', - result: `FAKE_CLAUDE_DONE_${turn}`, - num_turns: turn, - usage: { input_tokens: 1, output_tokens: 1 }, - total_cost_usd: 0, - duration_ms: Math.max(1, Date.now() - now), - duration_api_ms: 1, - is_error: false, - session_id: sessionId, - }; - - process.stdout.write(`${JSON.stringify(assistant)}\n`); - process.stdout.write(`${JSON.stringify(taskToolResult)}\n`); - process.stdout.write(`${JSON.stringify(taskOutputToolResult)}\n`); - process.stdout.write(`${JSON.stringify(result)}\n`); + emitSdk(assistant); + emitSdk(taskToolResult); + emitSdk(taskOutputToolResult); + emitSdk(createResultSuccess()); continue; } if (scenario === 'review-json') { - const assistant = { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'text', - text: JSON.stringify({ - summary: `FAKE_REVIEW_SUMMARY_${turn}`, - findings: [ - { - id: `f_${turn}_1`, - title: 'Fake finding', - severity: 'low', - category: 'style', - summary: 'Fake finding summary', - filePath: 'README.md', - startLine: 1, - endLine: 1, - suggestion: 'No-op', - }, - ], - }), - }, - ], + const assistant = createAssistantMessage([ + { + type: 'text', + text: JSON.stringify({ + summary: `FAKE_REVIEW_SUMMARY_${turn}`, + findings: [ + { + id: `f_${turn}_1`, + title: 'Fake finding', + severity: 'low', + category: 'style', + summary: 'Fake finding summary', + filePath: 'README.md', + startLine: 1, + endLine: 1, + suggestion: 'No-op', + }, + ], + }), }, - }; - const result = { - type: 'result', - subtype: 'success', - result: `FAKE_CLAUDE_DONE_${turn}`, - num_turns: turn, - usage: { input_tokens: 1, output_tokens: 1 }, - total_cost_usd: 0, - duration_ms: Math.max(1, Date.now() - now), - duration_api_ms: 1, - is_error: false, - session_id: sessionId, - }; - - process.stdout.write(`${JSON.stringify(assistant)}\n`); - process.stdout.write(`${JSON.stringify(result)}\n`); + ]); + + emitSdk(assistant); + emitSdk(createResultSuccess()); continue; } if (scenario === 'plan-json') { - const assistant = { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'text', - text: JSON.stringify({ - summary: `FAKE_PLAN_SUMMARY_${turn}`, - sections: [ - { title: 'Phase 1', items: ['Do the thing', 'Verify'] }, - ], - risks: ['Fake risk'], - milestones: [{ title: 'M1', details: 'Fake milestone' }], - recommendedBackendId: 'claude', - }), - }, - ], + const assistant = createAssistantMessage([ + { + type: 'text', + text: JSON.stringify({ + summary: `FAKE_PLAN_SUMMARY_${turn}`, + sections: [{ title: 'Phase 1', items: ['Do the thing', 'Verify'] }], + risks: ['Fake risk'], + milestones: [{ title: 'M1', details: 'Fake milestone' }], + recommendedBackendId: 'claude', + }), }, - }; - const result = { - type: 'result', - subtype: 'success', - result: `FAKE_CLAUDE_DONE_${turn}`, - num_turns: turn, - usage: { input_tokens: 1, output_tokens: 1 }, - total_cost_usd: 0, - duration_ms: Math.max(1, Date.now() - now), - duration_api_ms: 1, - is_error: false, - session_id: sessionId, - }; - - process.stdout.write(`${JSON.stringify(assistant)}\n`); - process.stdout.write(`${JSON.stringify(result)}\n`); + ]); + + emitSdk(assistant); + emitSdk(createResultSuccess()); continue; } if (scenario === 'delegate-json') { - const assistant = { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'text', - text: JSON.stringify({ - summary: `FAKE_DELEGATE_SUMMARY_${turn}`, - deliverables: [{ id: `d_${turn}_1`, title: 'Fake deliverable', details: 'Fake details' }], - }), - }, - ], + const assistant = createAssistantMessage([ + { + type: 'text', + text: JSON.stringify({ + summary: `FAKE_DELEGATE_SUMMARY_${turn}`, + deliverables: [{ id: `d_${turn}_1`, title: 'Fake deliverable', details: 'Fake details' }], + }), }, - }; - const result = { - type: 'result', - subtype: 'success', - result: `FAKE_CLAUDE_DONE_${turn}`, - num_turns: turn, - usage: { input_tokens: 1, output_tokens: 1 }, - total_cost_usd: 0, - duration_ms: Math.max(1, Date.now() - now), - duration_api_ms: 1, - is_error: false, - session_id: sessionId, - }; - - process.stdout.write(`${JSON.stringify(assistant)}\n`); - process.stdout.write(`${JSON.stringify(result)}\n`); + ]); + + emitSdk(assistant); + emitSdk(createResultSuccess()); continue; } if (scenario === 'commit-message-json') { - const assistant = { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'text', - text: JSON.stringify({ - title: 'feat: ephemeral commit message', - body: '', - message: 'feat: ephemeral commit message', - confidence: 1, - }), - }, - ], + const assistant = createAssistantMessage([ + { + type: 'text', + text: JSON.stringify({ + title: 'feat: ephemeral commit message', + body: '', + message: 'feat: ephemeral commit message', + confidence: 1, + }), }, - }; - const result = { - type: 'result', - subtype: 'success', - result: `FAKE_CLAUDE_DONE_${turn}`, - num_turns: turn, - usage: { input_tokens: 1, output_tokens: 1 }, - total_cost_usd: 0, - duration_ms: Math.max(1, Date.now() - now), - duration_api_ms: 1, - is_error: false, - session_id: sessionId, - }; - - process.stdout.write(`${JSON.stringify(assistant)}\n`); - process.stdout.write(`${JSON.stringify(result)}\n`); + ]); + + emitSdk(assistant); + emitSdk(createResultSuccess()); continue; } - const assistant = { - type: 'assistant', - message: { role: 'assistant', content: [{ type: 'text', text: `FAKE_CLAUDE_OK_${turn}` }] }, - }; - const result = { - type: 'result', - subtype: 'success', - result: `FAKE_CLAUDE_DONE_${turn}`, - num_turns: turn, - usage: { input_tokens: 1, output_tokens: 1 }, - total_cost_usd: 0, - duration_ms: Math.max(1, Date.now() - now), - duration_api_ms: 1, - is_error: false, - session_id: sessionId, - }; + const assistant = createAssistantMessage([{ type: 'text', text: `FAKE_CLAUDE_OK_${turn}` }]); - process.stdout.write(`${JSON.stringify(assistant)}\n`); - process.stdout.write(`${JSON.stringify(result)}\n`); + emitSdk(assistant); + emitSdk(createResultSuccess()); } rl.close(); @@ -448,26 +451,53 @@ async function runSdkStreamUntilEof() { } async function runPrintStreamJsonAndExit() { - const now = Date.now(); const systemInit = { type: 'system', subtype: 'init', - session_id: sessionId, + apiKeySource: 'project', + claude_code_version: '0.0.0-fake', cwd: process.cwd(), tools: ['Bash(echo)'], + mcp_servers: [], + model: 'fake-claude', + permissionMode: 'default', slash_commands: ['/help'], + output_style: 'default', + skills: [], + plugins: [], + uuid: randomUUID(), + session_id: sessionId, + }; + const assistant = { + type: 'assistant', + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: sessionId, + message: { + id: 'fake-print-assistant-1', + type: 'message', + role: 'assistant', + model: 'fake-claude', + content: [{ type: 'text', text: 'FAKE_CLAUDE_PRINT_OK' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, }; - const assistant = { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'FAKE_CLAUDE_PRINT_OK' }] } }; const result = { type: 'result', subtype: 'success', result: 'FAKE_CLAUDE_PRINT_DONE', num_turns: 1, usage: { input_tokens: 1, output_tokens: 1 }, + modelUsage: {}, + permission_denials: [], total_cost_usd: 0, - duration_ms: Math.max(1, Date.now() - now), + duration_ms: 1, duration_api_ms: 1, is_error: false, + stop_reason: null, + uuid: randomUUID(), session_id: sessionId, }; diff --git a/packages/tests/src/testkit/auth.ts b/packages/tests/src/testkit/auth.ts index 4952cf967..d54fa58b0 100644 --- a/packages/tests/src/testkit/auth.ts +++ b/packages/tests/src/testkit/auth.ts @@ -9,6 +9,10 @@ export type TestAuth = { publicKeyBase64: string; }; +export type TestAuthMtls = { + token: string; +}; + export async function createTestAuth(baseUrl: string): Promise<TestAuth> { const kp = tweetnacl.sign.keyPair(); // privacy-kit Bytes is `Uint8Array<ArrayBuffer>`; ensure our buffers are compatible across TS libs. @@ -36,3 +40,43 @@ export async function createTestAuth(baseUrl: string): Promise<TestAuth> { return { token: res.data.token, publicKeyBase64: body.publicKey }; } + +export async function createTestAuthMtls( + baseUrl: string, + identity: { + email?: string; + upn?: string; + subject?: string; + fingerprint?: string; + issuer?: string; + }, +): Promise<TestAuthMtls> { + const headers: Record<string, string> = {}; + if (typeof identity.email === 'string' && identity.email.trim()) { + headers['x-happier-client-cert-email'] = identity.email.trim(); + } + if (typeof identity.upn === 'string' && identity.upn.trim()) { + headers['x-happier-client-cert-upn'] = identity.upn.trim(); + } + if (typeof identity.subject === 'string' && identity.subject.trim()) { + headers['x-happier-client-cert-subject'] = identity.subject.trim(); + } + if (typeof identity.fingerprint === 'string' && identity.fingerprint.trim()) { + headers['x-happier-client-cert-sha256'] = identity.fingerprint.trim(); + } + if (typeof identity.issuer === 'string' && identity.issuer.trim()) { + headers['x-happier-client-cert-issuer'] = identity.issuer.trim(); + } + + const res = await fetchJson<{ token?: string; success?: boolean; error?: unknown }>(`${baseUrl}/v1/auth/mtls`, { + method: 'POST', + headers, + timeoutMs: 15_000, + }); + + if (res.status !== 200 || res.data?.success !== true || typeof res.data?.token !== 'string' || res.data.token.length === 0) { + throw new Error(`Failed to create test mTLS auth token (status=${res.status})`); + } + + return { token: res.data.token }; +} diff --git a/packages/tests/src/testkit/cliAccessKey.spec.ts b/packages/tests/src/testkit/cliAccessKey.spec.ts new file mode 100644 index 000000000..febdaefc1 --- /dev/null +++ b/packages/tests/src/testkit/cliAccessKey.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { readCliAccessKey } from './cliAccessKey'; +import { createRunDirs } from './runDir'; + +const run = createRunDirs({ runLabel: 'cli-access-key' }); + +describe('readCliAccessKey', () => { + it('falls back to newest per-server access.key when legacy root file is missing', async () => { + const dir = run.testDir('fallback-newest-per-server'); + const home = resolve(join(dir, 'cli-home')); + await mkdir(join(home, 'servers', 'server-a'), { recursive: true }); + await mkdir(join(home, 'servers', 'server-b'), { recursive: true }); + + const a = JSON.stringify({ token: 't-a', secret: 's-a' }) + '\n'; + const b = JSON.stringify({ token: 't-b', encryption: { publicKey: 'p-b', machineKey: 'm-b' } }) + '\n'; + await writeFile(join(home, 'servers', 'server-a', 'access.key'), a, 'utf8'); + // Ensure server-b has a strictly newer mtime. + await new Promise((r) => setTimeout(r, 10)); + await writeFile(join(home, 'servers', 'server-b', 'access.key'), b, 'utf8'); + + const key = await readCliAccessKey(home); + expect(key).toEqual({ token: 't-b', encryption: { publicKey: 'p-b', machineKey: 'm-b' } }); + }); + + it('prefers per-server access.key when settings.json activeServerId matches', async () => { + const dir = run.testDir('prefers-active-server'); + const home = resolve(join(dir, 'cli-home')); + await mkdir(join(home, 'servers', 'active'), { recursive: true }); + await mkdir(join(home, 'servers', 'other'), { recursive: true }); + + const active = JSON.stringify({ token: 't-active', secret: 's-active' }) + '\n'; + const other = JSON.stringify({ token: 't-other', encryption: { publicKey: 'p-other', machineKey: 'm-other' } }) + '\n'; + await writeFile(join(home, 'servers', 'active', 'access.key'), active, 'utf8'); + await writeFile(join(home, 'servers', 'other', 'access.key'), other, 'utf8'); + await writeFile(join(home, 'settings.json'), JSON.stringify({ schemaVersion: 5, activeServerId: 'active' }) + '\n', 'utf8'); + + const key = await readCliAccessKey(home); + expect(key).toEqual({ token: 't-active', secret: 's-active' }); + }); +}); diff --git a/packages/tests/src/testkit/cliAccessKey.ts b/packages/tests/src/testkit/cliAccessKey.ts new file mode 100644 index 000000000..6f9f889f2 --- /dev/null +++ b/packages/tests/src/testkit/cliAccessKey.ts @@ -0,0 +1,113 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +export type CliAccessKey = + | Readonly<{ + token: string; + secret: string; + }> + | Readonly<{ + token: string; + encryption: Readonly<{ + publicKey: string; + machineKey: string; + }>; + }>; + +function parseAccessKey(raw: string): CliAccessKey | null { + try { + const parsed = JSON.parse(raw) as { + token?: unknown; + secret?: unknown; + encryption?: unknown; + } | null; + const token = typeof parsed?.token === 'string' ? parsed.token.trim() : ''; + if (!token) return null; + + const secret = typeof parsed?.secret === 'string' ? parsed.secret.trim() : ''; + if (secret) return { token, secret }; + + if (parsed?.encryption && typeof parsed.encryption === 'object') { + const enc = parsed.encryption as Record<string, unknown>; + const publicKey = typeof enc.publicKey === 'string' ? enc.publicKey.trim() : ''; + const machineKey = typeof enc.machineKey === 'string' ? enc.machineKey.trim() : ''; + if (publicKey && machineKey) { + return { token, encryption: { publicKey, machineKey } }; + } + } + + return null; + } catch { + return null; + } +} + +async function readAccessKeyFromPath(path: string): Promise<CliAccessKey | null> { + try { + const raw = await readFile(path, 'utf8'); + return parseAccessKey(raw); + } catch { + return null; + } +} + +async function resolveActiveServerIdFromSettings(happyHomeDir: string): Promise<string | null> { + try { + const raw = await readFile(join(happyHomeDir, 'settings.json'), 'utf8'); + const parsed = JSON.parse(raw) as { schemaVersion?: number; activeServerId?: unknown } | null; + if (!parsed || typeof parsed.schemaVersion !== 'number') return null; + if (parsed.schemaVersion < 5) return null; + if (typeof parsed.activeServerId !== 'string' || !parsed.activeServerId) return null; + return parsed.activeServerId; + } catch { + return null; + } +} + +function perServerAccessKeyPath(happyHomeDir: string, serverId: string): string { + return join(happyHomeDir, 'servers', serverId, 'access.key'); +} + +async function listPerServerAccessKeyCandidates(happyHomeDir: string): Promise<Array<{ path: string; mtimeMs: number }>> { + const serversDir = join(happyHomeDir, 'servers'); + try { + const entries = await readdir(serversDir, { withFileTypes: true }); + const candidates: Array<{ path: string; mtimeMs: number }> = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidatePath = join(serversDir, entry.name, 'access.key'); + try { + const s = await stat(candidatePath); + candidates.push({ path: candidatePath, mtimeMs: s.mtimeMs }); + } catch { + // ignore missing / unreadable + } + } + return candidates; + } catch { + return []; + } +} + +export async function readCliAccessKey(happyHomeDir: string): Promise<CliAccessKey | null> { + const activeServerId = await resolveActiveServerIdFromSettings(happyHomeDir); + const candidates: string[] = []; + if (activeServerId) candidates.push(perServerAccessKeyPath(happyHomeDir, activeServerId)); + candidates.push(join(happyHomeDir, 'access.key')); + + for (const candidate of candidates) { + const key = await readAccessKeyFromPath(candidate); + if (key) return key; + } + + // Fallback: find the newest per-server access key (settings.json may be stale/mismatched during e2e). + const perServerKeys = await listPerServerAccessKeyCandidates(happyHomeDir); + if (perServerKeys.length === 0) return null; + perServerKeys.sort((a, b) => b.mtimeMs - a.mtimeMs); + for (const candidate of perServerKeys) { + const key = await readAccessKeyFromPath(candidate.path); + if (key) return key; + } + + return null; +} diff --git a/packages/tests/src/testkit/daemon/daemon.statePath.spec.ts b/packages/tests/src/testkit/daemon/daemon.statePath.spec.ts index f28a34000..f6118cb6f 100644 --- a/packages/tests/src/testkit/daemon/daemon.statePath.spec.ts +++ b/packages/tests/src/testkit/daemon/daemon.statePath.spec.ts @@ -54,5 +54,41 @@ describe('readDaemonState', () => { }), ); }); -}); + it('falls back to scanning servers/*/daemon.state.json when settings.json points elsewhere', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happier-daemon-state-fallback-')); + + // Persisted selection points to "cloud" but the daemon wrote its state under env_deadbeef. + await writeFile( + join(dir, 'settings.json'), + JSON.stringify( + { + schemaVersion: 5, + activeServerId: 'cloud', + servers: { + cloud: { id: 'cloud', serverUrl: 'https://api.happier.dev', webappUrl: 'https://app.happier.dev' }, + }, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + const serverId = 'env_deadbeef'; + const serverDir = join(dir, 'servers', serverId); + await mkdir(serverDir, { recursive: true }); + await writeFile( + join(serverDir, 'daemon.state.json'), + JSON.stringify({ pid: 111, httpPort: 222 }, null, 2) + '\n', + 'utf8', + ); + + await expect(readDaemonState(dir)).resolves.toEqual( + expect.objectContaining({ + pid: 111, + httpPort: 222, + }), + ); + }); +}); diff --git a/packages/tests/src/testkit/daemon/daemon.ts b/packages/tests/src/testkit/daemon/daemon.ts index 363b69b76..6789849cf 100644 --- a/packages/tests/src/testkit/daemon/daemon.ts +++ b/packages/tests/src/testkit/daemon/daemon.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, readdir, stat } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -48,6 +48,27 @@ async function readDaemonStateFromPath(path: string): Promise<DaemonState | null } } +async function listServerDaemonStateCandidates(happyHomeDir: string): Promise<Array<{ path: string; mtimeMs: number }>> { + const serversDir = join(happyHomeDir, 'servers'); + try { + const entries = await readdir(serversDir, { withFileTypes: true }); + const candidates: Array<{ path: string; mtimeMs: number }> = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidatePath = join(serversDir, entry.name, 'daemon.state.json'); + try { + const s = await stat(candidatePath); + candidates.push({ path: candidatePath, mtimeMs: s.mtimeMs }); + } catch { + // ignore missing / unreadable + } + } + return candidates; + } catch { + return []; + } +} + export async function readDaemonState(happyHomeDir: string): Promise<DaemonState | null> { const activeServerId = await resolveActiveServerIdFromSettings(happyHomeDir); const candidates: string[] = []; @@ -58,6 +79,15 @@ export async function readDaemonState(happyHomeDir: string): Promise<DaemonState const state = await readDaemonStateFromPath(candidate); if (state) return state; } + + // Fallback: if settings.json is stale/mismatched, find the newest per-server daemon state. + const perServerStates = await listServerDaemonStateCandidates(happyHomeDir); + if (perServerStates.length === 0) return null; + perServerStates.sort((a, b) => b.mtimeMs - a.mtimeMs); + for (const candidate of perServerStates) { + const state = await readDaemonStateFromPath(candidate.path); + if (state) return state; + } return null; } diff --git a/packages/tests/src/testkit/network/reserveAvailablePort.ts b/packages/tests/src/testkit/network/reserveAvailablePort.ts new file mode 100644 index 000000000..e422ce8a2 --- /dev/null +++ b/packages/tests/src/testkit/network/reserveAvailablePort.ts @@ -0,0 +1,21 @@ +import { createServer as createNetServer } from 'node:net'; + +export async function reserveAvailablePort(): Promise<number> { + return await new Promise<number>((resolvePort, reject) => { + const srv = createNetServer(); + srv.once('error', reject); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address(); + if (!addr || typeof addr !== 'object') { + srv.close(() => reject(new Error('missing address'))); + return; + } + const port = addr.port; + srv.close((err) => { + if (err) reject(err); + else resolvePort(port); + }); + }); + }); +} + diff --git a/packages/tests/src/testkit/oauth/fakeGithubOAuthServer.ts b/packages/tests/src/testkit/oauth/fakeGithubOAuthServer.ts new file mode 100644 index 000000000..5ba66331e --- /dev/null +++ b/packages/tests/src/testkit/oauth/fakeGithubOAuthServer.ts @@ -0,0 +1,80 @@ +import { once } from 'node:events'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; + +export type StopFn = () => Promise<void>; + +export async function startFakeGitHubOAuthServer(): Promise<{ + baseUrl: string; + stop: StopFn; + getCounts: () => Readonly<Record<string, number>>; +}> { + const counts: Record<string, number> = {}; + const inc = (key: string) => { + counts[key] = (counts[key] ?? 0) + 1; + }; + + const srv = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const pathname = url.pathname; + + if (pathname === '/login/oauth/authorize') { + inc('authorize'); + const redirectUri = url.searchParams.get('redirect_uri') ?? ''; + const state = url.searchParams.get('state') ?? ''; + if (!redirectUri || !state) { + res.statusCode = 400; + res.end('missing redirect params'); + return; + } + const redirect = new URL(redirectUri); + redirect.searchParams.set('code', 'fake_code_1'); + redirect.searchParams.set('state', state); + res.statusCode = 302; + res.setHeader('location', redirect.toString()); + res.end(); + return; + } + + if (pathname === '/login/oauth/access_token') { + inc('token'); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ access_token: 'tok_1' })); + return; + } + + if (pathname === '/user') { + inc('user'); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + id: 123, + login: 'octocat', + avatar_url: 'https://avatars.example.test/octo.png', + name: 'Octo Cat', + }), + ); + return; + } + + res.statusCode = 404; + res.end('not found'); + }); + + srv.listen(0, '127.0.0.1'); + await once(srv, 'listening'); + const addr = srv.address(); + if (!addr || typeof addr !== 'object') throw new Error('fake oauth server missing address'); + const baseUrl = `http://127.0.0.1:${addr.port}`; + + return { + baseUrl, + stop: async () => { + srv.close(); + await once(srv, 'close'); + }, + getCounts: () => ({ ...counts }), + }; +} + diff --git a/packages/tests/src/testkit/process/extendedDbDocker.plan.spec.ts b/packages/tests/src/testkit/process/extendedDbDocker.plan.spec.ts index 12df52f4f..7d609f59e 100644 --- a/packages/tests/src/testkit/process/extendedDbDocker.plan.spec.ts +++ b/packages/tests/src/testkit/process/extendedDbDocker.plan.spec.ts @@ -59,10 +59,12 @@ describe('extended db docker plan', () => { it('plans the correct yarn commands for e2e and db-contract modes', () => { const databaseUrl = buildDatabaseUrlForContainer({ db: 'postgres', host: '127.0.0.1', port: 5432 }); const e2e = buildExtendedDbCommandPlan({ db: 'postgres', mode: 'e2e', databaseUrl }); - expect(e2e).toHaveLength(1); - expect(e2e[0].kind).toBe('e2e'); - expect(e2e[0].env.HAPPIER_E2E_DB_PROVIDER).toBe('postgres'); - expect(e2e[0].env.DATABASE_URL).toBe(databaseUrl); + expect(e2e).toHaveLength(3); + expect(e2e[0].kind).toBe('prebuild-cli-shared'); + expect(e2e[1].kind).toBe('prebuild-cli'); + expect(e2e[2].kind).toBe('e2e'); + expect(e2e[2].env.HAPPIER_E2E_DB_PROVIDER).toBe('postgres'); + expect(e2e[2].env.DATABASE_URL).toBe(databaseUrl); const contract = buildExtendedDbCommandPlan({ db: 'mysql', mode: 'contract', databaseUrl: 'mysql://root:happier@127.0.0.1:3306/happier' }); expect(contract).toHaveLength(2); @@ -74,15 +76,17 @@ describe('extended db docker plan', () => { it('plans the correct yarn commands for extended mode (all steps)', () => { const databaseUrl = buildDatabaseUrlForContainer({ db: 'postgres', host: '127.0.0.1', port: 5432 }); const extended = buildExtendedDbCommandPlan({ db: 'postgres', mode: 'extended', databaseUrl }); - expect(extended).toHaveLength(3); - expect(extended[0].kind).toBe('e2e'); - expect(extended[0].env.HAPPIER_E2E_DB_PROVIDER).toBe('postgres'); - expect(extended[0].env.DATABASE_URL).toBe(databaseUrl); - expect(extended[1].kind).toBe('migrate'); - expect(extended[1].env.DATABASE_URL).toBe(databaseUrl); - expect(extended[2].kind).toBe('contract'); - expect(extended[2].env.HAPPIER_DB_PROVIDER).toBe('postgres'); + expect(extended).toHaveLength(5); + expect(extended[0].kind).toBe('prebuild-cli-shared'); + expect(extended[1].kind).toBe('prebuild-cli'); + expect(extended[2].kind).toBe('e2e'); + expect(extended[2].env.HAPPIER_E2E_DB_PROVIDER).toBe('postgres'); expect(extended[2].env.DATABASE_URL).toBe(databaseUrl); + expect(extended[3].kind).toBe('migrate'); + expect(extended[3].env.DATABASE_URL).toBe(databaseUrl); + expect(extended[4].kind).toBe('contract'); + expect(extended[4].env.HAPPIER_DB_PROVIDER).toBe('postgres'); + expect(extended[4].env.DATABASE_URL).toBe(databaseUrl); }); it('sanitizes docker env by dropping DOCKER_API_VERSION to allow negotiation', () => { diff --git a/packages/tests/src/testkit/process/uiWeb.baseUrl.spec.ts b/packages/tests/src/testkit/process/uiWeb.baseUrl.spec.ts new file mode 100644 index 000000000..219f32245 --- /dev/null +++ b/packages/tests/src/testkit/process/uiWeb.baseUrl.spec.ts @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { EventEmitter } from 'node:events'; + +let lastSpawnArgs: string[] | null = null; +let lastSpawnEnv: NodeJS.ProcessEnv | null = null; + +vi.mock('./spawnProcess', () => { + return { + spawnLoggedProcess: (params: { stdoutPath: string; stderrPath: string; args?: unknown; env?: unknown }) => { + if (Array.isArray(params.args)) lastSpawnArgs = params.args as string[]; + if (params.env && typeof params.env === 'object') lastSpawnEnv = params.env as NodeJS.ProcessEnv; + const child = new EventEmitter() as EventEmitter & { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }; + child.exitCode = null; + child.signalCode = null; + return { + child, + stdoutPath: params.stdoutPath, + stderrPath: params.stderrPath, + stop: async () => {}, + }; + }, + }; +}); + +function resolveUrlString(input: unknown): string { + if (typeof input === 'string') return input; + if (input && typeof input === 'object' && 'url' in input && typeof (input as { url?: unknown }).url === 'string') { + return (input as { url: string }).url; + } + return String(input); +} + +type FakeFetchResponse = { + ok: boolean; + headers: { get: (name: string) => string | null }; + text: () => Promise<string>; +}; + +function okText(body: string, contentType: string): FakeFetchResponse { + return { + ok: true, + headers: { get: (name) => name.toLowerCase() === 'content-type' ? contentType : null }, + text: async () => body, + }; +} + +function notOk(): FakeFetchResponse { + return { + ok: false, + headers: { get: () => null }, + text: async () => '', + }; +} + +describe('startUiWeb baseUrl resolution', () => { + beforeEach(() => { + vi.useRealTimers(); + lastSpawnArgs = null; + lastSpawnEnv = null; + }); + + it('prefers the Expo web entry page over Metro root HTML', async () => { + const { startUiWeb } = await import('./uiWeb'); + + const testDir = await mkdtemp(join(tmpdir(), 'happier-uiweb-')); + await writeFile(join(testDir, 'ui.web.stdout.log'), '', 'utf8'); + await writeFile(join(testDir, 'ui.web.stderr.log'), '', 'utf8'); + + const webEntryHtml = '<!doctype html><html><head><script src="/index.bundle?platform=web&dev=false&minify=true"></script></head></html>'; + const metroRootHtml = '<!doctype html><html><head></head><body>Metro Bundler</body></html>'; + let localhostWebAttempts = 0; + + const fetchMock = vi.fn(async (input: unknown): Promise<FakeFetchResponse> => { + const url = resolveUrlString(input); + const parsed = new URL(url); + + if (parsed.pathname === '/status') { + return okText('packager-status:running', 'text/plain'); + } + + if (parsed.pathname.startsWith('/index.bundle')) { + return okText('globalThis.__HAPPIER_E2E__ = true;', 'application/javascript'); + } + + if (parsed.port === '19006') { + if (parsed.hostname === 'localhost') { + localhostWebAttempts += 1; + return localhostWebAttempts >= 2 ? okText(webEntryHtml, 'text/html') : notOk(); + } + return notOk(); + } + + if (parsed.port === '8081' && parsed.pathname === '/') { + return okText(metroRootHtml, 'text/html'); + } + + return notOk(); + }); + + const originalFetch = globalThis.fetch; + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + try { + const started = await Promise.race([ + startUiWeb({ testDir, env: {} }), + new Promise<never>((_, reject) => { + setTimeout(() => { + const calledUrls = fetchMock.mock.calls + .map((call) => resolveUrlString(call[0])) + .slice(0, 20) + .join('\n'); + reject(new Error(`startUiWeb did not finish quickly; fetch calls=${fetchMock.mock.calls.length}\n${calledUrls}`)); + }, 5_000); + }), + ]); + expect(new URL(started.baseUrl).port).toBe('19006'); + await started.stop(); + } finally { + if (typeof originalFetch === 'function') { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } else { + delete (globalThis as { fetch?: unknown }).fetch; + } + } + }, 10_000); + + it('can resolve baseUrl to :8081 when it serves the Expo web entry page', async () => { + const { startUiWeb } = await import('./uiWeb'); + + const testDir = await mkdtemp(join(tmpdir(), 'happier-uiweb-')); + await writeFile(join(testDir, 'ui.web.stdout.log'), '', 'utf8'); + await writeFile(join(testDir, 'ui.web.stderr.log'), '', 'utf8'); + + const webEntryHtml = '<!doctype html><html><head><script src="/index.bundle?platform=web&dev=false&minify=true"></script></head></html>'; + + const fetchMock = vi.fn(async (input: unknown): Promise<FakeFetchResponse> => { + const url = resolveUrlString(input); + const parsed = new URL(url); + + if (parsed.pathname === '/status') { + return okText('packager-status:running', 'text/plain'); + } + + if (parsed.pathname.startsWith('/index.bundle')) { + return okText('globalThis.__HAPPIER_E2E__ = true;', 'application/javascript'); + } + + if (parsed.port === '19006') return notOk(); + if (parsed.port === '8081' && parsed.pathname === '/') return okText(webEntryHtml, 'text/plain'); + + return notOk(); + }); + + const originalFetch = globalThis.fetch; + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + try { + const started = await Promise.race([ + startUiWeb({ testDir, env: {} }), + new Promise<never>((_, reject) => { + setTimeout(() => { + const calledUrls = fetchMock.mock.calls + .map((call) => resolveUrlString(call[0])) + .slice(0, 20) + .join('\n'); + reject(new Error(`startUiWeb did not finish quickly; fetch calls=${fetchMock.mock.calls.length}\n${calledUrls}`)); + }, 5_000); + }), + ]); + expect(new URL(started.baseUrl).port).toBe('8081'); + await started.stop(); + } finally { + if (typeof originalFetch === 'function') { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } else { + delete (globalThis as { fetch?: unknown }).fetch; + } + } + }, 10_000); + + it('does not clear Metro cache by default', async () => { + const { startUiWeb } = await import('./uiWeb'); + + const testDir = await mkdtemp(join(tmpdir(), 'happier-uiweb-')); + await writeFile(join(testDir, 'ui.web.stdout.log'), '', 'utf8'); + await writeFile(join(testDir, 'ui.web.stderr.log'), '', 'utf8'); + + const webEntryHtml = '<!doctype html><html><head><script src="/index.bundle?platform=web&dev=false&minify=true"></script></head></html>'; + + const fetchMock = vi.fn(async (input: unknown): Promise<FakeFetchResponse> => { + const url = resolveUrlString(input); + const parsed = new URL(url); + + if (parsed.pathname === '/status') { + return okText('packager-status:running', 'text/plain'); + } + + if (parsed.pathname.startsWith('/index.bundle')) { + return okText('globalThis.__HAPPIER_E2E__ = true;', 'application/javascript'); + } + + if (parsed.port === '19006') return notOk(); + if (parsed.port === '8081' && parsed.pathname === '/') return okText(webEntryHtml, 'text/html'); + + return notOk(); + }); + + const originalFetch = globalThis.fetch; + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + try { + const started = await Promise.race([ + startUiWeb({ testDir, env: {} }), + new Promise<never>((_, reject) => { + setTimeout(() => { + reject(new Error('startUiWeb did not finish quickly')); + }, 5_000); + }), + ]); + + expect(lastSpawnArgs).not.toBeNull(); + expect(lastSpawnArgs ?? []).not.toContain('--clear'); + expect(typeof lastSpawnEnv?.TMPDIR).toBe('string'); + expect(String(lastSpawnEnv?.TMPDIR ?? '')).toContain(testDir); + await started.stop(); + } finally { + if (typeof originalFetch === 'function') { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } else { + delete (globalThis as { fetch?: unknown }).fetch; + } + } + }, 10_000); + + it('can enable clearing Metro cache via env', async () => { + const { startUiWeb } = await import('./uiWeb'); + + const testDir = await mkdtemp(join(tmpdir(), 'happier-uiweb-')); + await writeFile(join(testDir, 'ui.web.stdout.log'), '', 'utf8'); + await writeFile(join(testDir, 'ui.web.stderr.log'), '', 'utf8'); + + const webEntryHtml = '<!doctype html><html><head><script src="/index.bundle?platform=web&dev=false&minify=true"></script></head></html>'; + + const fetchMock = vi.fn(async (input: unknown): Promise<FakeFetchResponse> => { + const url = resolveUrlString(input); + const parsed = new URL(url); + + if (parsed.pathname === '/status') { + return okText('packager-status:running', 'text/plain'); + } + + if (parsed.pathname.startsWith('/index.bundle')) { + return okText('globalThis.__HAPPIER_E2E__ = true;', 'application/javascript'); + } + + if (parsed.port === '19006') return notOk(); + if (parsed.port === '8081' && parsed.pathname === '/') return okText(webEntryHtml, 'text/html'); + + return notOk(); + }); + + const originalFetch = globalThis.fetch; + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + try { + const started = await Promise.race([ + startUiWeb({ testDir, env: { HAPPIER_E2E_EXPO_CLEAR: '1' } }), + new Promise<never>((_, reject) => { + setTimeout(() => { + reject(new Error('startUiWeb did not finish quickly')); + }, 5_000); + }), + ]); + + expect(lastSpawnArgs).not.toBeNull(); + expect(lastSpawnArgs ?? []).toContain('--clear'); + await started.stop(); + } finally { + if (typeof originalFetch === 'function') { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } else { + delete (globalThis as { fetch?: unknown }).fetch; + } + } + }, 10_000); +}); diff --git a/packages/tests/src/testkit/process/uiWeb.ts b/packages/tests/src/testkit/process/uiWeb.ts new file mode 100644 index 000000000..c362376ba --- /dev/null +++ b/packages/tests/src/testkit/process/uiWeb.ts @@ -0,0 +1,255 @@ +import { mkdir, readFile } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; +import { createServer } from 'node:net'; + +import { repoRootDir } from '../paths'; +import { waitFor } from '../timing'; +import { spawnLoggedProcess, type SpawnedProcess } from './spawnProcess'; +import { resolveScriptUrlsFromHtml, selectPrimaryAppScriptUrl } from './uiWebHtml'; + +export type StartedUiWeb = { + baseUrl: string; + proc: SpawnedProcess; + stop: () => Promise<void>; +}; + +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, ''); +} + +function extractHttpUrls(text: string): string[] { + const out: string[] = []; + const sanitized = stripAnsi(text); + const pattern = /\bhttps?:\/\/[^\s)]+/g; + for (const match of sanitized.matchAll(pattern)) { + const url = match[0]; + if (!url) continue; + if (!out.includes(url)) out.push(url); + } + return out; +} + +async function looksLikeUiWebEntryPage(url: string): Promise<boolean> { + try { + const res = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(2_000) }); + if (!res.ok) return false; + const text = await res.text().catch(() => ''); + if (!text.includes('<html') && !text.toLowerCase().includes('<!doctype html')) return false; + + const scripts = resolveScriptUrlsFromHtml(text, url); + if (scripts.length === 0) return false; + const primary = selectPrimaryAppScriptUrl(scripts); + if (!primary) return false; + // Base URL selection only needs to ensure we're looking at the Expo web entry HTML. + // Bundle readiness is handled later (after Metro /status is confirmed). + return true; + } catch { + return false; + } +} + +async function resolveExpoWebBaseUrl(params: { stdoutPath: string; timeoutMs: number; expectedPort?: number }): Promise<string> { + const defaultCandidates = [ + 'http://localhost:19006', + 'http://127.0.0.1:19006', + 'http://localhost:8081', + 'http://127.0.0.1:8081', + ]; + + const expectedCandidates = + typeof params.expectedPort === 'number' && Number.isFinite(params.expectedPort) && params.expectedPort > 0 + ? [`http://localhost:${params.expectedPort}`, `http://127.0.0.1:${params.expectedPort}`] + : []; + + let resolved: string | null = null; + await waitFor(async () => { + const text = await readFile(params.stdoutPath, 'utf8').catch(() => ''); + + const stdoutCandidates = extractHttpUrls(text).map((url) => url.replace(/\/+$/, '')); + const orderedCandidates: string[] = []; + const seen = new Set<string>(); + + const fallbacks = expectedCandidates.length > 0 ? [] : defaultCandidates; + for (const raw of [...stdoutCandidates, ...expectedCandidates, ...fallbacks]) { + const url = raw.trim().replace(/\/+$/, ''); + if (!url) continue; + if (seen.has(url)) continue; + seen.add(url); + orderedCandidates.push(url); + } + + for (const url of orderedCandidates) { + if (await looksLikeUiWebEntryPage(url)) { + resolved = url; + return true; + } + } + return false; + }, { timeoutMs: params.timeoutMs, intervalMs: 250, context: 'expo web ready' }); + + if (resolved) return resolved; + + // The waitFor above succeeded but did not set a baseUrl (should be unreachable). Re-check candidates for safety. + for (const url of expectedCandidates.length > 0 ? expectedCandidates : defaultCandidates) { + if (await looksLikeUiWebEntryPage(url)) return url; + } + + throw new Error(`Failed to resolve Expo web baseUrl from stdout log: ${params.stdoutPath}`); +} + +async function isMetroPackagerReady(baseUrl: string): Promise<boolean> { + try { + const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/status`, { method: 'GET', signal: AbortSignal.timeout(2_000) }); + if (!res.ok) return false; + const text = await res.text().catch(() => ''); + return text.includes('packager-status:running'); + } catch { + return false; + } +} + +async function isScriptReady(url: string): Promise<boolean> { + try { + // Metro holds the response open while bundling; short timeouts can repeatedly abort the build and + // keep the progress stuck at 0%. Allow enough time for a cold build to complete. + const res = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(30_000) }); + if (!res.ok) return false; + const contentType = (res.headers.get('content-type') ?? '').toLowerCase(); + if (contentType.includes('javascript')) return true; + const text = await res.text().catch(() => ''); + return text.includes('__d(') || text.includes('webpackBootstrap') || text.includes('globalThis'); + } catch { + return false; + } +} + +async function resolveAvailablePort(): Promise<number> { + await new Promise<void>((resolve) => setTimeout(resolve, 0)); + return await new Promise<number>((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address !== 'object') { + server.close(() => reject(new Error('Failed to resolve available port (missing address)'))); + return; + } + const port = address.port; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +export async function startUiWeb(params: { + testDir: string; + env: NodeJS.ProcessEnv; + port?: number; +}): Promise<StartedUiWeb> { + const stdoutPath = resolvePath(params.testDir, 'ui.web.stdout.log'); + const stderrPath = resolvePath(params.testDir, 'ui.web.stderr.log'); + + const clearRaw = (params.env.HAPPIER_E2E_EXPO_CLEAR ?? '').toString().trim().toLowerCase(); + const clearCache = clearRaw === '1' || clearRaw === 'true' || clearRaw === 'yes' || clearRaw === 'y'; + + const expoCliPath = resolvePath(repoRootDir(), 'node_modules', 'expo', 'bin', 'cli'); + const uiWorkspaceDir = resolvePath(repoRootDir(), 'apps', 'ui'); + const tmpDir = resolvePath(params.testDir, 'ui.web.tmp'); + await mkdir(tmpDir, { recursive: true }); + const metroPort = typeof params.port === 'number' && Number.isFinite(params.port) && params.port > 0 + ? params.port + : await resolveAvailablePort(); + + const proc = spawnLoggedProcess({ + args: [ + expoCliPath, + 'start', + '--web', + '--host', + 'localhost', + '--port', + String(metroPort), + ...(clearCache ? ['--clear'] : []), + ], + command: process.execPath, + cwd: uiWorkspaceDir, + env: { + ...params.env, + CI: '1', + EXPO_NO_TELEMETRY: '1', + EXPO_UNSTABLE_WEB_MODAL: '1', + BROWSER: 'none', + // Isolate Metro cache per run to avoid stale EXPO_PUBLIC_* values being reused across E2E runs. + // Metro cache defaults under os.tmpdir(), which can be shared across processes and users. + TMPDIR: tmpDir, + TMP: tmpDir, + TEMP: tmpDir, + }, + stdoutPath, + stderrPath, + }); + + let baseUrl: string; + try { + const exitedEarly = new Promise<never>((_, reject) => { + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + const detail = signal ? `signal=${signal}` : `code=${code ?? 'null'}`; + reject(new Error(`expo web dev server exited before ready (${detail})`)); + }; + proc.child.once('exit', onExit); + if (proc.child.exitCode !== null || proc.child.signalCode !== null) { + proc.child.off('exit', onExit); + onExit(proc.child.exitCode, proc.child.signalCode as NodeJS.Signals | null); + } + }); + + baseUrl = await Promise.race([ + resolveExpoWebBaseUrl({ stdoutPath, timeoutMs: 180_000, expectedPort: metroPort }), + exitedEarly, + ]); + + // Even if the web page is served (often on :19006), Metro may still be initializing (or misconfigured). + // Ensure Metro is actually ready before handing back control to Playwright. + await waitFor( + async () => + (await isMetroPackagerReady(`http://localhost:${metroPort}`)) + || (await isMetroPackagerReady(`http://127.0.0.1:${metroPort}`)), + { timeoutMs: 120_000, intervalMs: 250, context: 'metro /status ready' }, + ); + + // Metro can serve HTML before the initial app bundle is ready. Parse the web entry HTML and wait for + // the primary script to be fetchable so Playwright navigation doesn't hang on DOMContentLoaded. + await waitFor(async () => { + const html = await fetch(baseUrl, { method: 'GET', signal: AbortSignal.timeout(2_000) }) + .then((r) => r.ok ? r.text() : '') + .catch(() => ''); + const scripts = resolveScriptUrlsFromHtml(html, baseUrl); + const primary = scripts.length > 0 ? selectPrimaryAppScriptUrl(scripts) : null; + if (!primary) return false; + return await isScriptReady(primary); + }, { timeoutMs: 420_000, intervalMs: 250, context: 'web bundle ready' }); + } catch (e) { + await proc.stop().catch(() => {}); + const stdoutText = await readFile(stdoutPath, 'utf8').catch(() => ''); + const stderrText = await readFile(stderrPath, 'utf8').catch(() => ''); + const tailLimit = 8_000; + const stdoutTail = stdoutText.slice(Math.max(0, stdoutText.length - tailLimit)); + const stderrTail = stderrText.slice(Math.max(0, stderrText.length - tailLimit)); + const detail = [ + e instanceof Error ? e.message : String(e), + `stdoutTail=${JSON.stringify(stdoutTail)}`, + `stderrTail=${JSON.stringify(stderrTail)}`, + ].join(' | '); + throw new Error(detail); + } + + return { + baseUrl, + proc, + stop: async () => { + await proc.stop().catch(() => {}); + }, + }; +} diff --git a/packages/tests/src/testkit/process/uiWebHtml.spec.ts b/packages/tests/src/testkit/process/uiWebHtml.spec.ts new file mode 100644 index 000000000..1eb08d283 --- /dev/null +++ b/packages/tests/src/testkit/process/uiWebHtml.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveScriptUrlsFromHtml, selectPrimaryAppScriptUrl } from './uiWebHtml'; + +describe('uiWebHtml', () => { + it('resolves script src urls against the baseUrl', () => { + const html = [ + '<!doctype html>', + '<html>', + '<head>', + '<script src="/index.bundle?platform=web&dev=false&minify=true"></script>', + '<script src="https://cdn.example.com/vendor.js"></script>', + '</head>', + '</html>', + ].join('\n'); + + const urls = resolveScriptUrlsFromHtml(html, 'http://localhost:8081/'); + expect(urls).toEqual([ + 'http://localhost:8081/index.bundle?platform=web&dev=false&minify=true', + 'https://cdn.example.com/vendor.js', + ]); + }); + + it('selects the primary bundle-like script url when present', () => { + const urls = [ + 'http://localhost:8081/runtime.js', + 'http://localhost:8081/index.bundle?platform=web&dev=false&minify=true', + 'http://localhost:8081/vendor.js', + ]; + expect(selectPrimaryAppScriptUrl(urls)).toBe('http://localhost:8081/index.bundle?platform=web&dev=false&minify=true'); + }); + + it('falls back to the first script url when no bundle-like url exists', () => { + const urls = [ + 'http://localhost:19006/static/js/runtime.js', + 'http://localhost:19006/static/js/vendor.js', + ]; + expect(selectPrimaryAppScriptUrl(urls)).toBe('http://localhost:19006/static/js/runtime.js'); + }); +}); diff --git a/packages/tests/src/testkit/process/uiWebHtml.ts b/packages/tests/src/testkit/process/uiWebHtml.ts new file mode 100644 index 000000000..0a7a9438f --- /dev/null +++ b/packages/tests/src/testkit/process/uiWebHtml.ts @@ -0,0 +1,34 @@ +function extractScriptSrcsFromHtml(html: string): string[] { + const out: string[] = []; + const pattern = /<script\b[^>]*\bsrc=(?:"([^"]+)"|'([^']+)')[^>]*>/gi; + for (const match of html.matchAll(pattern)) { + const src = match[1] ?? match[2] ?? ''; + if (!src) continue; + out.push(src); + } + return out; +} + +export function resolveScriptUrlsFromHtml(html: string, baseUrl: string): string[] { + const srcs = extractScriptSrcsFromHtml(html); + const out: string[] = []; + for (const src of srcs) { + try { + out.push(new URL(src, baseUrl).toString()); + } catch { + // ignore invalid urls + } + } + return out; +} + +export function selectPrimaryAppScriptUrl(urls: readonly string[]): string | null { + const prefer = (u: string) => + u.includes('index.bundle') + || u.includes('bundle.js') + || u.includes('main.js'); + + const match = urls.find((u) => prefer(u)) ?? null; + return match ?? (urls[0] ?? null); +} + diff --git a/packages/tests/src/testkit/providers/presets/presets.d.mts b/packages/tests/src/testkit/providers/presets/presets.d.mts index f53994aed..0c08f6f3d 100644 --- a/packages/tests/src/testkit/providers/presets/presets.d.mts +++ b/packages/tests/src/testkit/providers/presets/presets.d.mts @@ -1,4 +1,4 @@ -export type ProviderPresetId = 'opencode' | 'claude' | 'codex' | 'kilo' | 'gemini' | 'qwen' | 'kimi' | 'auggie' | 'all'; +export type ProviderPresetId = 'opencode' | 'claude' | 'codex' | 'kilo' | 'gemini' | 'qwen' | 'kimi' | 'auggie' | 'pi' | 'copilot' | 'all'; export type ProviderConcretePresetId = Exclude<ProviderPresetId, 'all'>; export type ProviderAcpPresetId = Exclude<ProviderConcretePresetId, 'claude'>; export type ProviderScenarioTier = 'smoke' | 'extended'; diff --git a/packages/tests/src/testkit/providers/presets/presets.mjs b/packages/tests/src/testkit/providers/presets/presets.mjs index 28bfb313c..f3043536e 100644 --- a/packages/tests/src/testkit/providers/presets/presets.mjs +++ b/packages/tests/src/testkit/providers/presets/presets.mjs @@ -8,6 +8,7 @@ export const PROVIDER_ENV_FLAG_BY_PRESET_ID = Object.freeze({ kimi: 'HAPPIER_E2E_PROVIDER_KIMI', auggie: 'HAPPIER_E2E_PROVIDER_AUGGIE', pi: 'HAPPIER_E2E_PROVIDER_PI', + copilot: 'HAPPIER_E2E_PROVIDER_COPILOT', }); export const PROVIDER_PRESET_IDS = Object.freeze(Object.keys(PROVIDER_ENV_FLAG_BY_PRESET_ID)); diff --git a/packages/tests/src/testkit/providers/presets/presets.ts b/packages/tests/src/testkit/providers/presets/presets.ts index 0dda34f36..89241ec41 100644 --- a/packages/tests/src/testkit/providers/presets/presets.ts +++ b/packages/tests/src/testkit/providers/presets/presets.ts @@ -7,7 +7,7 @@ import { resolveProviderRunPreset as resolveProviderRunPresetImpl, } from './presets.mjs'; -export type ProviderPresetId = 'opencode' | 'claude' | 'codex' | 'kilo' | 'gemini' | 'qwen' | 'kimi' | 'auggie' | 'pi' | 'all'; +export type ProviderPresetId = 'opencode' | 'claude' | 'codex' | 'kilo' | 'gemini' | 'qwen' | 'kimi' | 'auggie' | 'pi' | 'copilot' | 'all'; export type ProviderScenarioTier = 'smoke' | 'extended'; export type ProviderConcretePresetId = Exclude<ProviderPresetId, 'all'>; export type ProviderAcpPresetId = Exclude<ProviderConcretePresetId, 'claude'>; diff --git a/packages/tests/src/testkit/providers/types.ts b/packages/tests/src/testkit/providers/types.ts index 50c9aefb3..cc7047ae9 100644 --- a/packages/tests/src/testkit/providers/types.ts +++ b/packages/tests/src/testkit/providers/types.ts @@ -1,4 +1,4 @@ -export type KnownProviderId = 'opencode' | 'codex' | 'claude' | 'kilo' | 'qwen' | 'kimi' | 'gemini' | 'auggie' | 'pi'; +export type KnownProviderId = 'opencode' | 'codex' | 'claude' | 'kilo' | 'qwen' | 'kimi' | 'gemini' | 'auggie' | 'pi' | 'copilot'; export type ProviderId = KnownProviderId | (string & { readonly __providerIdBrand?: unique symbol }); export type ProviderProtocol = 'acp' | 'codex' | 'claude'; diff --git a/packages/tests/src/testkit/socialFriends.ts b/packages/tests/src/testkit/socialFriends.ts new file mode 100644 index 000000000..2ec180f3e --- /dev/null +++ b/packages/tests/src/testkit/socialFriends.ts @@ -0,0 +1,43 @@ +import { fetchJson } from './http'; + +export async function fetchAccountId(baseUrl: string, token: string): Promise<string> { + const res = await fetchJson<any>(`${baseUrl}/v1/account/profile`, { + headers: { Authorization: `Bearer ${token}` }, + timeoutMs: 15_000, + }); + if (res.status !== 200 || typeof res.data?.id !== 'string' || res.data.id.length === 0) { + throw new Error(`Failed to fetch account profile (status=${res.status})`); + } + return res.data.id; +} + +export async function setUsername(baseUrl: string, token: string, username: string): Promise<void> { + const res = await fetchJson<any>(`${baseUrl}/v1/account/username`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username }), + timeoutMs: 15_000, + }); + if (res.status !== 200) { + throw new Error(`Failed to set username ${username} (status=${res.status})`); + } +} + +export async function addFriend(baseUrl: string, token: string, uid: string): Promise<void> { + const res = await fetchJson<any>(`${baseUrl}/v1/friends/add`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ uid }), + timeoutMs: 15_000, + }); + if (res.status !== 200) { + throw new Error(`Failed to add friend ${uid} (status=${res.status})`); + } +} + diff --git a/packages/tests/src/testkit/uiE2e/cliJson.ts b/packages/tests/src/testkit/uiE2e/cliJson.ts new file mode 100644 index 000000000..cd0d14f00 --- /dev/null +++ b/packages/tests/src/testkit/uiE2e/cliJson.ts @@ -0,0 +1,72 @@ +import { readFile } from 'node:fs/promises'; +import { join, resolve as resolvePath } from 'node:path'; + +import { ensureCliDistBuilt } from '../process/cliDist'; +import { runLoggedCommand } from '../process/spawnProcess'; +import { repoRootDir } from '../paths'; + +export type JsonEnvelope = { + ok: boolean; + kind: string; + data?: unknown; + error?: unknown; +}; + +function pickLastJsonEnvelope(text: string): JsonEnvelope { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]; + if (!line) continue; + if (!(line.startsWith('{') || line.startsWith('['))) continue; + try { + const parsed = JSON.parse(line) as JsonEnvelope; + if (parsed && typeof parsed === 'object' && typeof parsed.ok === 'boolean' && typeof parsed.kind === 'string') { + return parsed; + } + } catch { + // keep scanning backwards + } + } + throw new Error(`Failed to parse JSON envelope from CLI stdout: ${JSON.stringify(lines.slice(-20).join('\n'))}`); +} + +export async function runCliJson(params: Readonly<{ + testDir: string; + cliHomeDir: string; + serverUrl: string; + webappUrl: string; + env: NodeJS.ProcessEnv; + label: string; + args: string[]; + timeoutMs?: number; +}>): Promise<JsonEnvelope> { + const cliDistEntrypoint = await ensureCliDistBuilt({ testDir: params.testDir, env: params.env }); + const stdoutPath = resolvePath(join(params.testDir, `cli.${params.label}.stdout.log`)); + const stderrPath = resolvePath(join(params.testDir, `cli.${params.label}.stderr.log`)); + + await runLoggedCommand({ + command: process.execPath, + args: [cliDistEntrypoint, ...params.args], + cwd: repoRootDir(), + env: { + ...params.env, + CI: '1', + HAPPIER_SESSION_AUTOSTART_DAEMON: '0', + HAPPIER_HOME_DIR: params.cliHomeDir, + HAPPIER_SERVER_URL: params.serverUrl, + HAPPIER_WEBAPP_URL: params.webappUrl, + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + }, + stdoutPath, + stderrPath, + timeoutMs: params.timeoutMs, + }); + + const stdoutText = await readFile(stdoutPath, 'utf8').catch(() => ''); + return pickLastJsonEnvelope(stdoutText); +} + diff --git a/packages/tests/src/testkit/uiE2e/cliTerminalConnect.ts b/packages/tests/src/testkit/uiE2e/cliTerminalConnect.ts new file mode 100644 index 000000000..f9fdcf68e --- /dev/null +++ b/packages/tests/src/testkit/uiE2e/cliTerminalConnect.ts @@ -0,0 +1,135 @@ +import { readFile } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; + +import { ensureCliDistBuilt } from '../process/cliDist'; +import { spawnLoggedProcess, type SpawnedProcess } from '../process/spawnProcess'; +import { repoRootDir } from '../paths'; +import { waitForRegexInFile } from '../waitForRegexInFile'; + +function extractHttpUrls(text: string): string[] { + const out: string[] = []; + const pattern = /\bhttps?:\/\/[^\s)]+/g; + for (const match of text.matchAll(pattern)) { + const url = match[0]; + if (!url) continue; + if (!out.includes(url)) out.push(url); + } + return out; +} + +function normalizeUrl(raw: string): string { + return raw.replaceAll(/\u001b\[[0-9;]*m/g, '').trim().replace(/^[('"]+/, '').replace(/[)'".,]+$/, ''); +} + +function extractTerminalConnectUrl(text: string): string | null { + for (const raw of extractHttpUrls(text)) { + const cleaned = normalizeUrl(raw); + if (!cleaned.includes('/terminal/connect#key=')) continue; + return cleaned; + } + return null; +} + +async function stdoutTail(path: string): Promise<string> { + const raw = await readFile(path, 'utf8').catch(() => ''); + return raw.slice(Math.max(0, raw.length - 8_000)); +} + +async function stderrTail(path: string): Promise<string> { + const raw = await readFile(path, 'utf8').catch(() => ''); + return raw.slice(Math.max(0, raw.length - 8_000)); +} + +async function waitForExit(proc: SpawnedProcess, timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + if (proc.child.exitCode !== null || proc.child.signalCode !== null) { + return { code: proc.child.exitCode, signal: proc.child.signalCode as NodeJS.Signals | null }; + } + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timed out waiting for CLI process to exit after ${timeoutMs}ms`)); + }, timeoutMs); + proc.child.once('exit', (code, signal) => { + clearTimeout(timer); + resolve({ code, signal: signal as NodeJS.Signals | null }); + }); + }); +} + +export type StartedCliTerminalConnect = { + connectUrl: string; + proc: SpawnedProcess; + waitForSuccess: () => Promise<void>; + stop: () => Promise<void>; +}; + +export async function startCliAuthLoginForTerminalConnect(params: Readonly<{ + testDir: string; + cliHomeDir: string; + serverUrl: string; + webappUrl: string; + env: NodeJS.ProcessEnv; +}>): Promise<StartedCliTerminalConnect> { + const cliDistEntrypoint = await ensureCliDistBuilt({ testDir: params.testDir, env: params.env }); + + const stdoutPath = resolvePath(params.testDir, 'cli.auth.login.stdout.log'); + const stderrPath = resolvePath(params.testDir, 'cli.auth.login.stderr.log'); + + const proc = spawnLoggedProcess({ + command: process.execPath, + args: [cliDistEntrypoint, 'auth', 'login', '--force', '--no-open', '--method', 'web'], + cwd: repoRootDir(), + env: { + ...params.env, + CI: '1', + HAPPIER_SESSION_AUTOSTART_DAEMON: '0', + HAPPIER_HOME_DIR: params.cliHomeDir, + HAPPIER_SERVER_URL: params.serverUrl, + HAPPIER_WEBAPP_URL: params.webappUrl, + }, + stdoutPath, + stderrPath, + }); + + let connectUrl: string | null = null; + try { + const match = await waitForRegexInFile({ + path: stdoutPath, + regex: /https?:\/\/[^\s)]+\/terminal\/connect#key=[^\s]+/, + timeoutMs: 90_000, + pollMs: 100, + context: 'CLI terminal connect URL', + }); + connectUrl = extractTerminalConnectUrl(match.input ?? '') ?? normalizeUrl(match[0] ?? ''); + } catch (e) { + await proc.stop().catch(() => {}); + throw e; + } + + if (!connectUrl) { + const tail = await stdoutTail(stdoutPath); + await proc.stop().catch(() => {}); + throw new Error(`Failed to extract terminal connect URL from CLI stdout | stdoutTail=${JSON.stringify(tail)}`); + } + + return { + connectUrl, + proc, + waitForSuccess: async () => { + const { code, signal } = await waitForExit(proc, 120_000); + if (code === 0) return; + const detail = signal ? `signal=${signal}` : `code=${code ?? 'null'}`; + const outTail = await stdoutTail(stdoutPath); + const errTail = await stderrTail(stderrPath); + throw new Error( + [ + `CLI auth login exited with ${detail}`, + `stdoutTail=${JSON.stringify(outTail)}`, + `stderrTail=${JSON.stringify(errTail)}`, + ].join(' | '), + ); + }, + stop: async () => { + await proc.stop().catch(() => {}); + }, + }; +} diff --git a/packages/tests/src/testkit/uiE2e/forwardedHeaderProxy.ts b/packages/tests/src/testkit/uiE2e/forwardedHeaderProxy.ts new file mode 100644 index 000000000..5a0b4d58a --- /dev/null +++ b/packages/tests/src/testkit/uiE2e/forwardedHeaderProxy.ts @@ -0,0 +1,137 @@ +import { createServer, type IncomingMessage, type ServerResponse, request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { connect as netConnect } from 'node:net'; +import { connect as tlsConnect } from 'node:tls'; +import { once } from 'node:events'; + +type ProxyStop = () => Promise<void>; + +function setCors(res: ServerResponse) { + res.setHeader('access-control-allow-origin', '*'); + res.setHeader('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); + res.setHeader('access-control-allow-headers', 'authorization,content-type'); + res.setHeader('access-control-max-age', '600'); +} + +function coerceHeaders(headers: IncomingMessage['headers']): Record<string, string> { + const out: Record<string, string> = {}; + for (const [k, v] of Object.entries(headers)) { + if (!k) continue; + if (k.toLowerCase() === 'host') continue; + if (typeof v === 'string') out[k] = v; + else if (Array.isArray(v)) out[k] = v.join(', '); + } + return out; +} + +export async function startForwardedHeaderProxy(params: { + targetBaseUrl: string; + identityHeaders: Record<string, string>; +}): Promise<{ baseUrl: string; stop: ProxyStop }> { + const targetUrl = new URL(params.targetBaseUrl); + const isHttps = targetUrl.protocol === 'https:'; + const targetPort = Number(targetUrl.port || (isHttps ? 443 : 80)); + const requestFn = isHttps ? httpsRequest : httpRequest; + + const srv = createServer((req: IncomingMessage, res: ServerResponse) => { + if (req.method === 'OPTIONS') { + setCors(res); + res.statusCode = 204; + res.end(); + return; + } + + const upstreamUrl = new URL(req.url ?? '/', targetUrl); + const headers = coerceHeaders(req.headers); + for (const [k, v] of Object.entries(params.identityHeaders)) { + headers[k] = v; + } + + const upstreamReq = requestFn( + { + protocol: targetUrl.protocol, + hostname: targetUrl.hostname, + port: targetPort, + method: req.method, + path: `${upstreamUrl.pathname}${upstreamUrl.search}`, + headers: { + ...headers, + host: targetUrl.host, + }, + }, + (upstreamRes) => { + setCors(res); + res.statusCode = upstreamRes.statusCode ?? 502; + for (const [k, v] of Object.entries(upstreamRes.headers)) { + if (!k) continue; + if (k.toLowerCase() === 'access-control-allow-origin') continue; + if (typeof v === 'string') res.setHeader(k, v); + else if (Array.isArray(v)) res.setHeader(k, v); + } + upstreamRes.pipe(res); + }, + ); + + upstreamReq.on('error', () => { + setCors(res); + res.statusCode = 502; + res.end('bad_gateway'); + }); + + req.pipe(upstreamReq); + }); + + srv.on('upgrade', (req, socket, head) => { + // Tunnel websocket upgrades directly to the upstream server to support daemon + UI socket clients. + const connectFn = isHttps ? tlsConnect : netConnect; + const upstream = connectFn( + { + host: targetUrl.hostname, + port: targetPort, + servername: isHttps ? targetUrl.hostname : undefined, + } as any, + () => { + const headers = coerceHeaders(req.headers); + for (const [k, v] of Object.entries(params.identityHeaders)) { + headers[k] = v; + } + + const lines: string[] = []; + lines.push(`${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/1.1`); + lines.push(`Host: ${targetUrl.host}`); + for (const [k, v] of Object.entries(headers)) { + lines.push(`${k}: ${v}`); + } + lines.push('\r\n'); + upstream.write(lines.join('\r\n')); + if (head && head.length > 0) upstream.write(head); + + socket.pipe(upstream); + upstream.pipe(socket); + }, + ); + + upstream.on('error', () => { + try { + socket.destroy(); + } catch { + // ignore + } + }); + }); + + srv.listen(0, '127.0.0.1'); + await once(srv, 'listening'); + const addr = srv.address(); + if (!addr || typeof addr !== 'object') throw new Error('proxy missing address'); + const baseUrl = `http://127.0.0.1:${addr.port}`; + + return { + baseUrl, + stop: async () => { + srv.close(); + await once(srv, 'close'); + }, + }; +} + diff --git a/packages/tests/src/testkit/uiE2e/pageNavigation.ts b/packages/tests/src/testkit/uiE2e/pageNavigation.ts new file mode 100644 index 000000000..2500813ac --- /dev/null +++ b/packages/tests/src/testkit/uiE2e/pageNavigation.ts @@ -0,0 +1,39 @@ +import type { Page } from '@playwright/test'; + +export function normalizeLoopbackBaseUrl(input: string): string { + try { + const parsed = new URL(input); + if (parsed.hostname === 'localhost') parsed.hostname = '127.0.0.1'; + return parsed.toString().replace(/\/+$/, ''); + } catch { + return input.replace(/\/+$/, ''); + } +} + +export async function gotoDomContentLoadedWithRetries(page: Page, url: string, timeoutMs = 90_000): Promise<void> { + const retryable = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('net::ERR_NETWORK_CHANGED') + || message.includes('net::ERR_CONNECTION_REFUSED') + || message.includes('net::ERR_CONNECTION_RESET') + || message.includes('net::ERR_ABORTED') + ); + }; + + const start = Date.now(); + let attempt = 0; + // Metro can briefly restart or drop connections during bundling; retry a few times for stability. + while (attempt < 4) { + attempt += 1; + try { + const remaining = Math.max(5_000, timeoutMs - (Date.now() - start)); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: remaining }); + return; + } catch (error) { + if (attempt >= 4 || !retryable(error)) throw error; + await page.waitForTimeout(500 * attempt); + } + } +} + diff --git a/packages/tests/src/testkit/waitForRegexInFile.ts b/packages/tests/src/testkit/waitForRegexInFile.ts new file mode 100644 index 000000000..40b0217cc --- /dev/null +++ b/packages/tests/src/testkit/waitForRegexInFile.ts @@ -0,0 +1,46 @@ +import { readFile } from 'node:fs/promises'; + +import { waitFor } from './timing'; + +export async function waitForRegexInFile(params: Readonly<{ + path: string; + regex: RegExp; + timeoutMs?: number; + pollMs?: number; + context?: string; +}>): Promise<RegExpMatchArray> { + const timeoutMs = params.timeoutMs ?? 60_000; + const pollMs = params.pollMs ?? 100; + + let lastText = ''; + let lastMatch: RegExpMatchArray | null = null; + const regex = new RegExp(params.regex.source, params.regex.flags.replace('g', '')); + + await waitFor( + async () => { + lastText = await readFile(params.path, 'utf8').catch(() => ''); + lastMatch = lastText.match(regex); + return Boolean(lastMatch); + }, + { + timeoutMs, + intervalMs: pollMs, + context: params.context ?? `regex match in file ${params.path}`, + }, + ); + + if (!lastMatch) { + const tail = lastText.slice(Math.max(0, lastText.length - 4_000)); + throw new Error( + [ + 'Timed out waiting for regex in file', + `path=${params.path}`, + `regex=/${params.regex.source}/${params.regex.flags}`, + `tail=${JSON.stringify(tail)}`, + ].join(' | '), + ); + } + + return lastMatch; +} + diff --git a/packages/tests/suites/core-e2e/auth.mtls.keyless.plaintext.roundtrip.feat.auth.mtls.feat.encryption.plaintextStorage.e2e.test.ts b/packages/tests/suites/core-e2e/auth.mtls.keyless.plaintext.roundtrip.feat.auth.mtls.feat.encryption.plaintextStorage.e2e.test.ts new file mode 100644 index 000000000..ef1478ed3 --- /dev/null +++ b/packages/tests/suites/core-e2e/auth.mtls.keyless.plaintext.roundtrip.feat.auth.mtls.feat.encryption.plaintextStorage.e2e.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuthMtls } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: keyless mTLS auth roundtrip (plaintext)', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('auto-provisions a keyless account and can create plaintext sessions', async () => { + const testDir = run.testDir('auth-mtls-keyless-plaintext-roundtrip'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: '0', + AUTH_ANONYMOUS_SIGNUP_ENABLED: '0', + AUTH_SIGNUP_PROVIDERS: '', + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: 'plain', + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: '1', + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: '1', + HAPPIER_FEATURE_AUTH_MTLS__MODE: 'forwarded', + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: '1', + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: '1', + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: 'san_email', + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: 'example.com', + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS: 'CN=Example Root CA', + }, + }); + + const auth = await createTestAuthMtls(server.baseUrl, { + email: 'alice@example.com', + issuer: 'CN=Example Root CA', + }); + + const mode = await fetchJson<any>(`${server.baseUrl}/v1/account/encryption`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(mode.status).toBe(200); + expect(mode.data?.mode).toBe('plain'); + + const create = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-mtls-plain', + metadata: JSON.stringify({ v: 1, flavor: 'claude', tag: 'e2e-mtls-plain' }), + agentState: null, + dataEncryptionKey: null, + }), + timeoutMs: 15_000, + }); + expect(create.status).toBe(200); + const sessionId = create.data?.session?.id; + expect(typeof sessionId).toBe('string'); + expect(create.data?.session?.encryptionMode).toBe('plain'); + + const localId = 'm-mtls-plain-1'; + const commit = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': localId, + }, + body: JSON.stringify({ + localId, + content: { + t: 'plain', + v: { role: 'user', content: { type: 'text', text: 'hello via mtls' } }, + }, + }), + timeoutMs: 15_000, + }); + expect(commit.status).toBe(200); + expect(commit.data?.didWrite).toBe(true); + + const messages = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/messages?limit=10`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(messages.status).toBe(200); + const first = messages.data?.messages?.[0]; + expect(first?.content?.t).toBe('plain'); + expect(first?.content?.v?.content?.text).toBe('hello via mtls'); + }, 240_000); +}); + diff --git a/packages/tests/suites/core-e2e/claude.fastStart.createSessionDelay.e2e.test.ts b/packages/tests/suites/core-e2e/claude.fastStart.createSessionDelay.e2e.test.ts index 3cefa70d5..b0f0bd93c 100644 --- a/packages/tests/suites/core-e2e/claude.fastStart.createSessionDelay.e2e.test.ts +++ b/packages/tests/suites/core-e2e/claude.fastStart.createSessionDelay.e2e.test.ts @@ -75,7 +75,7 @@ describe('core e2e: Claude fast-start', () => { HAPPIER_E2E_FAKE_CLAUDE_LOG: fakeLog, HAPPIER_E2E_FAKE_CLAUDE_SESSION_ID: `fake-claude-session-${randomUUID()}`, // Make server session creation slow enough that we can verify local spawn happens first. - HAPPIER_E2E_DELAY_CREATE_SESSION_MS: '10000', + HAPPIER_E2E_DELAY_CREATE_SESSION_MS: '30000', }; await ensureCliDistBuilt({ testDir, env: cliEnv }); @@ -102,7 +102,7 @@ describe('core e2e: Claude fast-start', () => { const invocation = await waitForFakeClaudeInvocation( fakeLog, (i) => i.mode === 'local', - { timeoutMs: 5000, pollMs: 25 }, + { timeoutMs: 20_000, pollMs: 25 }, ); expect(invocation.argv).toEqual(expect.any(Array)); diff --git a/packages/tests/suites/core-e2e/daemon.tmux.spawn.respawn.slow.e2e.test.ts b/packages/tests/suites/core-e2e/daemon.tmux.spawn.respawn.slow.e2e.test.ts index afe897847..b261438e5 100644 --- a/packages/tests/suites/core-e2e/daemon.tmux.spawn.respawn.slow.e2e.test.ts +++ b/packages/tests/suites/core-e2e/daemon.tmux.spawn.respawn.slow.e2e.test.ts @@ -6,18 +6,15 @@ import { spawnSync } from 'node:child_process'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; -import { createRunDirs } from '../../src/testkit/runDir'; -import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; -import { createTestAuth } from '../../src/testkit/auth'; -import { sleep, waitFor } from '../../src/testkit/timing'; -import { repoRootDir } from '../../src/testkit/paths'; -import { runLoggedCommand } from '../../src/testkit/process/spawnProcess'; -import { writeTestManifestForServer } from '../../src/testkit/manifestForServer'; -import { startTestDaemon, type StartedDaemon } from '../../src/testkit/daemon/daemon'; -import { yarnCommand } from '../../src/testkit/process/commands'; -import { fakeClaudeFixturePath } from '../../src/testkit/fakeClaude'; -import { daemonControlPostJson } from '../../src/testkit/daemon/controlServerClient'; -import { seedCliDataKeyAuthForServer } from '../../src/testkit/cliAuth'; + import { createRunDirs } from '../../src/testkit/runDir'; + import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + import { createTestAuth } from '../../src/testkit/auth'; + import { sleep, waitFor } from '../../src/testkit/timing'; + import { writeTestManifestForServer } from '../../src/testkit/manifestForServer'; + import { startTestDaemon, type StartedDaemon } from '../../src/testkit/daemon/daemon'; + import { fakeClaudeFixturePath } from '../../src/testkit/fakeClaude'; + import { daemonControlPostJson } from '../../src/testkit/daemon/controlServerClient'; + import { seedCliDataKeyAuthForServer } from '../../src/testkit/cliAuth'; function tmuxAvailable(): boolean { if (process.platform === 'win32') return false; @@ -125,30 +122,20 @@ describe('core e2e: daemon tmux spawn respawn supervision', () => { const machineKey = Uint8Array.from(randomBytes(32)); await seedCliDataKeyAuthForServer({ cliHome: daemonHomeDir, serverUrl: serverBaseUrl, token: auth.token, machineKey }); - writeTestManifestForServer({ - testDir, - server, - startedAt, - runId: run.runId, - testName: 'daemon-tmux-spawn-respawn', - sessionIds: [], - env: { - CI: process.env.CI, - HAPPIER_HOME_DIR: daemonHomeDir, - HAPPIER_SERVER_URL: serverBaseUrl, - HAPPIER_WEBAPP_URL: serverBaseUrl, - }, - }); - - await runLoggedCommand({ - command: yarnCommand(), - args: ['-s', 'workspace', '@happier-dev/cli', 'build'], - cwd: repoRootDir(), - env: { ...process.env, CI: '1' }, - stdoutPath: resolve(join(testDir, 'cli.build.stdout.log')), - stderrPath: resolve(join(testDir, 'cli.build.stderr.log')), - timeoutMs: 240_000, - }); + writeTestManifestForServer({ + testDir, + server, + startedAt, + runId: run.runId, + testName: 'daemon-tmux-spawn-respawn', + sessionIds: [], + env: { + CI: process.env.CI, + HAPPIER_HOME_DIR: daemonHomeDir, + HAPPIER_SERVER_URL: serverBaseUrl, + HAPPIER_WEBAPP_URL: serverBaseUrl, + }, + }); try { daemon = await startTestDaemon({ @@ -291,7 +278,6 @@ describe('core e2e: daemon tmux spawn respawn supervision', () => { } } } - }, - 120_000, - ); -}); + }, + ); + }); diff --git a/packages/tests/suites/core-e2e/encryption.plaintextStorage.accountModeSwitch.keepsExistingSessionsReadable.feat.encryption.plaintextStorage.e2e.test.ts b/packages/tests/suites/core-e2e/encryption.plaintextStorage.accountModeSwitch.keepsExistingSessionsReadable.feat.encryption.plaintextStorage.e2e.test.ts new file mode 100644 index 000000000..76a2bca81 --- /dev/null +++ b/packages/tests/suites/core-e2e/encryption.plaintextStorage.accountModeSwitch.keepsExistingSessionsReadable.feat.encryption.plaintextStorage.e2e.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: account encryption mode switching keeps existing sessions readable', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('supports e2ee → plain → e2ee without mutating prior sessions and preserves read access', async () => { + const testDir = run.testDir('encryption-account-mode-switch'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT: '1', + }, + }); + + const auth = await createTestAuth(server.baseUrl); + + const createE2eeSession = async (tag: string) => { + const res = await fetchJson<any>(`${server!.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag, + // Provide ciphertext-like strings; server stores them as-is. + metadata: Buffer.from(`cipher-meta-${tag}`, 'utf8').toString('base64'), + agentState: null, + dataEncryptionKey: Buffer.from(`data-key-${tag}`, 'utf8').toString('base64'), + }), + timeoutMs: 15_000, + }); + expect(res.status).toBe(200); + expect(res.data?.session?.encryptionMode).toBe('e2ee'); + const sessionId = res.data?.session?.id; + expect(typeof sessionId).toBe('string'); + return String(sessionId); + }; + + const createPlainSession = async (tag: string) => { + const res = await fetchJson<any>(`${server!.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag, + metadata: JSON.stringify({ v: 1, tag, path: '/tmp', flavor: 'claude' }), + agentState: null, + dataEncryptionKey: null, + }), + timeoutMs: 15_000, + }); + expect(res.status).toBe(200); + expect(res.data?.session?.encryptionMode).toBe('plain'); + const sessionId = res.data?.session?.id; + expect(typeof sessionId).toBe('string'); + return String(sessionId); + }; + + const readFirstMessageContentType = async (sessionId: string): Promise<'encrypted' | 'plain'> => { + const messages = await fetchJson<any>(`${server!.baseUrl}/v1/sessions/${sessionId}/messages?limit=10`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(messages.status).toBe(200); + const first = messages.data?.messages?.[0]; + const t = first?.content?.t; + if (t !== 'encrypted' && t !== 'plain') { + throw new Error(`Unexpected message content.t: ${JSON.stringify(t)}`); + } + return t; + }; + + const patchAccountMode = async (mode: 'plain' | 'e2ee') => { + const patch = await fetchJson<any>(`${server!.baseUrl}/v1/account/encryption`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ mode }), + timeoutMs: 15_000, + }); + expect(patch.status).toBe(200); + expect(patch.data?.mode).toBe(mode); + }; + + const sessionA = await createE2eeSession('e2e-switch-a'); + const writeA = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionA}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + localId: 'm-a-1', + ciphertext: Buffer.from('cipher-a-1', 'utf8').toString('base64'), + }), + timeoutMs: 15_000, + }); + expect(writeA.status).toBe(200); + expect(writeA.data?.didWrite).toBe(true); + expect(await readFirstMessageContentType(sessionA)).toBe('encrypted'); + + await patchAccountMode('plain'); + const sessionB = await createPlainSession('e2e-switch-b'); + const writeB = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionB}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + localId: 'm-b-1', + content: { t: 'plain', v: { role: 'user', content: { type: 'text', text: 'plain-b-1' } } }, + }), + timeoutMs: 15_000, + }); + expect(writeB.status).toBe(200); + expect(writeB.data?.didWrite).toBe(true); + expect(await readFirstMessageContentType(sessionB)).toBe('plain'); + + await patchAccountMode('e2ee'); + // Create a new session without explicitly setting encryptionMode, but still providing encryption materials. + // This asserts that the account mode influences the server's default selection, while satisfying required fields. + const createC = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-switch-c', + metadata: Buffer.from('cipher-meta-c', 'utf8').toString('base64'), + agentState: null, + dataEncryptionKey: Buffer.from('data-key-c', 'utf8').toString('base64'), + }), + timeoutMs: 15_000, + }); + expect(createC.status).toBe(200); + expect(createC.data?.session?.encryptionMode).toBe('e2ee'); + const sessionC = String(createC.data?.session?.id); + expect(sessionC).toMatch(/\S+/); + + const writeC = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionC}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + localId: 'm-c-1', + ciphertext: Buffer.from('cipher-c-1', 'utf8').toString('base64'), + }), + timeoutMs: 15_000, + }); + expect(writeC.status).toBe(200); + expect(writeC.data?.didWrite).toBe(true); + expect(await readFirstMessageContentType(sessionC)).toBe('encrypted'); + + // Existing sessions remain accessible and preserve their encryptionMode. + const sessionARecord = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionA}`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(sessionARecord.status).toBe(200); + expect(sessionARecord.data?.session?.encryptionMode).toBe('e2ee'); + + const sessionBRecord = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionB}`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(sessionBRecord.status).toBe(200); + expect(sessionBRecord.data?.session?.encryptionMode).toBe('plain'); + + expect(await readFirstMessageContentType(sessionA)).toBe('encrypted'); + expect(await readFirstMessageContentType(sessionB)).toBe('plain'); + }, 180_000); +}); diff --git a/packages/tests/suites/core-e2e/encryption.plaintextStorage.defaultAccountMode.roundtrip.feat.encryption.plaintextStorage.e2e.test.ts b/packages/tests/suites/core-e2e/encryption.plaintextStorage.defaultAccountMode.roundtrip.feat.encryption.plaintextStorage.e2e.test.ts new file mode 100644 index 000000000..6bba8ab6f --- /dev/null +++ b/packages/tests/suites/core-e2e/encryption.plaintextStorage.defaultAccountMode.roundtrip.feat.encryption.plaintextStorage.e2e.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: plaintext default account mode', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('defaults new accounts and sessions to plaintext when storagePolicy is optional and defaultAccountMode is plain', async () => { + const testDir = run.testDir('encryption-plaintext-default-account-mode'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT: '1', + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: 'plain', + }, + }); + + const auth = await createTestAuth(server.baseUrl); + + const currentMode = await fetchJson<any>(`${server.baseUrl}/v1/account/encryption`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(currentMode.status).toBe(200); + expect(currentMode.data?.mode).toBe('plain'); + + const create = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-plaintext-default-account-mode', + metadata: JSON.stringify({ v: 1, path: '/tmp', flavor: 'claude' }), + agentState: null, + dataEncryptionKey: null, + }), + timeoutMs: 15_000, + }); + expect(create.status).toBe(200); + const sessionId = create.data?.session?.id; + expect(typeof sessionId).toBe('string'); + expect(create.data?.session?.encryptionMode).toBe('plain'); + + const localId = 'm-local-default-plain-1'; + const commit = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': localId, + }, + body: JSON.stringify({ + localId, + content: { + t: 'plain', + v: { role: 'user', content: { type: 'text', text: 'hello default' } }, + }, + }), + timeoutMs: 15_000, + }); + expect(commit.status).toBe(200); + expect(commit.data?.didWrite).toBe(true); + + const messages = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/messages?limit=10`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(messages.status).toBe(200); + const first = messages.data?.messages?.[0]; + expect(first?.content?.t).toBe('plain'); + expect(first?.content?.v?.content?.text).toBe('hello default'); + }, 180_000); +}); + diff --git a/packages/tests/suites/core-e2e/encryption.plaintextStorage.directShare.roundtrip.feat.encryption.plaintextStorage.feat.sharing.session.feat.social.friends.e2e.test.ts b/packages/tests/suites/core-e2e/encryption.plaintextStorage.directShare.roundtrip.feat.encryption.plaintextStorage.feat.sharing.session.feat.social.friends.e2e.test.ts new file mode 100644 index 000000000..0cf375625 --- /dev/null +++ b/packages/tests/suites/core-e2e/encryption.plaintextStorage.directShare.roundtrip.feat.encryption.plaintextStorage.feat.sharing.session.feat.social.friends.e2e.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; +import { addFriend, fetchAccountId, setUsername } from '../../src/testkit/socialFriends'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: plaintext direct share roundtrip', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('shares plaintext sessions without encryptedDataKey and recipient can read plain messages', async () => { + const testDir = run.testDir('encryption-plaintext-direct-share-roundtrip'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT: '1', + HAPPIER_FEATURE_SOCIAL_FRIENDS__ENABLED: '1', + HAPPIER_FEATURE_SOCIAL_FRIENDS__ALLOW_USERNAME: '1', + }, + }); + + const owner = await createTestAuth(server.baseUrl); + const recipient = await createTestAuth(server.baseUrl); + + const ownerId = await fetchAccountId(server.baseUrl, owner.token); + const recipientId = await fetchAccountId(server.baseUrl, recipient.token); + + await setUsername(server.baseUrl, owner.token, 'owner_plain_share'); + await setUsername(server.baseUrl, recipient.token, 'recipient_plain_share'); + + // Establish friendship (request + accept). + await addFriend(server.baseUrl, owner.token, recipientId); + await addFriend(server.baseUrl, recipient.token, ownerId); + + const patchMode = await fetchJson<any>(`${server.baseUrl}/v1/account/encryption`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${owner.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ mode: 'plain' }), + timeoutMs: 15_000, + }); + expect(patchMode.status).toBe(200); + expect(patchMode.data?.mode).toBe('plain'); + + const create = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${owner.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-plaintext-direct-share', + encryptionMode: 'plain', + metadata: JSON.stringify({ v: 1, path: '/tmp', flavor: 'claude' }), + agentState: null, + dataEncryptionKey: null, + }), + timeoutMs: 15_000, + }); + expect(create.status).toBe(200); + const sessionId = create.data?.session?.id; + expect(typeof sessionId).toBe('string'); + expect(create.data?.session?.encryptionMode).toBe('plain'); + + const localId = 'm-local-share-plain-1'; + const commit = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${owner.token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': localId, + }, + body: JSON.stringify({ + localId, + content: { + t: 'plain', + v: { role: 'user', content: { type: 'text', text: 'hello direct share' } }, + }, + }), + timeoutMs: 15_000, + }); + expect(commit.status).toBe(200); + expect(commit.data?.didWrite).toBe(true); + + const share = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/shares`, { + method: 'POST', + headers: { + Authorization: `Bearer ${owner.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId: recipientId, + accessLevel: 'view', + }), + timeoutMs: 15_000, + }); + expect(share.status).toBe(200); + expect(typeof share.data?.share?.id).toBe('string'); + + const messages = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/messages?limit=10`, { + headers: { Authorization: `Bearer ${recipient.token}` }, + timeoutMs: 15_000, + }); + expect(messages.status).toBe(200); + expect(Array.isArray(messages.data?.messages)).toBe(true); + const anyPlain = (messages.data?.messages ?? []).some((row: any) => row?.content?.t === 'plain' && row?.content?.v?.content?.text === 'hello direct share'); + expect(anyPlain).toBe(true); + }, 180_000); +}); diff --git a/packages/tests/suites/core-e2e/encryption.plaintextStorage.messageModeEnforcement.feat.encryption.plaintextStorage.e2e.test.ts b/packages/tests/suites/core-e2e/encryption.plaintextStorage.messageModeEnforcement.feat.encryption.plaintextStorage.e2e.test.ts new file mode 100644 index 000000000..390c99288 --- /dev/null +++ b/packages/tests/suites/core-e2e/encryption.plaintextStorage.messageModeEnforcement.feat.encryption.plaintextStorage.e2e.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: message mode enforcement', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('rejects plaintext writes into e2ee sessions and rejects ciphertext writes into plaintext sessions', async () => { + const testDir = run.testDir('encryption-message-mode-enforcement'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT: '1', + }, + }); + + const auth = await createTestAuth(server.baseUrl); + + const e2eeCreate = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-enforced-e2ee', + encryptionMode: 'e2ee', + metadata: Buffer.from('cipher-meta', 'utf8').toString('base64'), + agentState: null, + dataEncryptionKey: Buffer.from('test-data-key', 'utf8').toString('base64'), + }), + timeoutMs: 15_000, + }); + expect(e2eeCreate.status).toBe(200); + const e2eeSessionId = e2eeCreate.data?.session?.id; + expect(typeof e2eeSessionId).toBe('string'); + expect(e2eeCreate.data?.session?.encryptionMode).toBe('e2ee'); + + const ciphertext = Buffer.from('e2ee-msg', 'utf8').toString('base64'); + const e2eeOk = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${e2eeSessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ciphertext, localId: 'm-enforce-e2ee-1' }), + timeoutMs: 15_000, + }); + expect(e2eeOk.status).toBe(200); + expect(e2eeOk.data?.didWrite).toBe(true); + + const e2eeRejectPlain = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${e2eeSessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + localId: 'm-enforce-e2ee-plain-1', + content: { t: 'plain', v: { role: 'user', content: { type: 'text', text: 'should-fail' } } }, + }), + timeoutMs: 15_000, + }); + expect(e2eeRejectPlain.status).toBe(400); + expect(e2eeRejectPlain.data?.error).toBe('Invalid parameters'); + + const e2eeMessages = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${e2eeSessionId}/messages?limit=10`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(e2eeMessages.status).toBe(200); + expect(e2eeMessages.data?.messages?.[0]?.content?.t).toBe('encrypted'); + + const plainCreate = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-enforced-plain', + encryptionMode: 'plain', + metadata: JSON.stringify({ v: 1, tag: 'e2e-enforced-plain' }), + agentState: null, + dataEncryptionKey: null, + }), + timeoutMs: 15_000, + }); + expect(plainCreate.status).toBe(200); + const plainSessionId = plainCreate.data?.session?.id; + expect(typeof plainSessionId).toBe('string'); + expect(plainCreate.data?.session?.encryptionMode).toBe('plain'); + + const plainOk = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${plainSessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + localId: 'm-enforce-plain-1', + content: { t: 'plain', v: { role: 'user', content: { type: 'text', text: 'plain-ok' } } }, + }), + timeoutMs: 15_000, + }); + expect(plainOk.status).toBe(200); + expect(plainOk.data?.didWrite).toBe(true); + + const plainRejectCiphertext = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${plainSessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ciphertext: Buffer.from('nope', 'utf8').toString('base64'), localId: 'm-enforce-plain-ct-1' }), + timeoutMs: 15_000, + }); + expect(plainRejectCiphertext.status).toBe(400); + expect(plainRejectCiphertext.data?.error).toBe('Invalid parameters'); + + const plainMessages = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${plainSessionId}/messages?limit=10`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(plainMessages.status).toBe(200); + const first = plainMessages.data?.messages?.[0]; + expect(first?.content?.t).toBe('plain'); + expect(first?.content?.v?.content?.text).toBe('plain-ok'); + }, 180_000); +}); + diff --git a/packages/tests/suites/core-e2e/encryption.plaintextStorage.pendingQueueV2.materialize.roundtrip.feat.encryption.plaintextStorage.feat.sharing.pendingQueueV2.e2e.test.ts b/packages/tests/suites/core-e2e/encryption.plaintextStorage.pendingQueueV2.materialize.roundtrip.feat.encryption.plaintextStorage.feat.sharing.pendingQueueV2.e2e.test.ts new file mode 100644 index 000000000..d38143d64 --- /dev/null +++ b/packages/tests/suites/core-e2e/encryption.plaintextStorage.pendingQueueV2.materialize.roundtrip.feat.encryption.plaintextStorage.feat.sharing.pendingQueueV2.e2e.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: plaintext pending queue v2 materialize-next', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('enqueues and materializes plaintext pending items into plaintext transcript messages', async () => { + const testDir = run.testDir('encryption-plaintext-pending-queue-v2-materialize'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT: '1', + }, + }); + + const auth = await createTestAuth(server.baseUrl); + + const patchMode = await fetchJson<any>(`${server.baseUrl}/v1/account/encryption`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ mode: 'plain' }), + timeoutMs: 15_000, + }); + expect(patchMode.status).toBe(200); + expect(patchMode.data?.mode).toBe('plain'); + + const create = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-plaintext-pending', + encryptionMode: 'plain', + metadata: JSON.stringify({ v: 1, path: '/tmp', flavor: 'claude' }), + agentState: null, + dataEncryptionKey: null, + }), + timeoutMs: 15_000, + }); + expect(create.status).toBe(200); + const sessionId = create.data?.session?.id; + expect(typeof sessionId).toBe('string'); + expect(create.data?.session?.encryptionMode).toBe('plain'); + + const localId = 'pending-local-plain-1'; + const enqueue = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/pending`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + localId, + content: { + t: 'plain', + v: { role: 'user', content: { type: 'text', text: 'hello pending plain' } }, + }, + }), + timeoutMs: 15_000, + }); + expect(enqueue.status).toBe(200); + expect(enqueue.data?.didWrite).toBe(true); + expect(enqueue.data?.pending?.localId).toBe(localId); + expect(enqueue.data?.pending?.content?.t).toBe('plain'); + + const list1 = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/pending`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(list1.status).toBe(200); + expect(list1.data?.pending?.[0]?.localId).toBe(localId); + expect(list1.data?.pending?.[0]?.content?.t).toBe('plain'); + expect(list1.data?.pending?.[0]?.content?.v?.content?.text).toBe('hello pending plain'); + + const materialize = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/pending/materialize-next`, { + method: 'POST', + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 20_000, + }); + expect(materialize.status).toBe(200); + expect(materialize.data?.ok).toBe(true); + expect(materialize.data?.didMaterialize).toBe(true); + expect(materialize.data?.didWriteMessage).toBe(true); + expect(materialize.data?.message?.localId).toBe(localId); + + const list2 = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/pending`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(list2.status).toBe(200); + expect(list2.data?.pending?.length ?? 0).toBe(0); + + const messages = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/messages?limit=10`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(messages.status).toBe(200); + const first = messages.data?.messages?.[0]; + expect(first?.localId).toBe(localId); + expect(first?.content?.t).toBe('plain'); + expect(first?.content?.v?.content?.text).toBe('hello pending plain'); + }, 180_000); +}); + diff --git a/packages/tests/suites/core-e2e/encryption.plaintextStorage.plaintextOnlyPolicy.guards.feat.encryption.plaintextStorage.e2e.test.ts b/packages/tests/suites/core-e2e/encryption.plaintextStorage.plaintextOnlyPolicy.guards.feat.encryption.plaintextStorage.e2e.test.ts new file mode 100644 index 000000000..f1fde49de --- /dev/null +++ b/packages/tests/suites/core-e2e/encryption.plaintextStorage.plaintextOnlyPolicy.guards.feat.encryption.plaintextStorage.e2e.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: plaintext_only policy guards', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('rejects e2ee session creation and encrypted writes under plaintext_only, while allowing plaintext writes', async () => { + const testDir = run.testDir('encryption-plaintext-only-policy-guards'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'plaintext_only', + }, + }); + + const auth = await createTestAuth(server.baseUrl); + + const rejected = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-plaintext-only-reject-e2ee', + encryptionMode: 'e2ee', + metadata: Buffer.from('cipher-meta', 'utf8').toString('base64'), + agentState: null, + dataEncryptionKey: Buffer.from('test-data-key', 'utf8').toString('base64'), + }), + timeoutMs: 15_000, + }); + expect(rejected.status).toBe(400); + expect(rejected.data?.error).toBe('invalid-params'); + + const create = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-plaintext-only-session', + metadata: JSON.stringify({ v: 1, tag: 'e2e-plaintext-only-session' }), + agentState: null, + dataEncryptionKey: null, + }), + timeoutMs: 15_000, + }); + expect(create.status).toBe(200); + const sessionId = create.data?.session?.id; + expect(typeof sessionId).toBe('string'); + expect(create.data?.session?.encryptionMode).toBe('plain'); + + const rejectEncrypted = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + localId: 'm-pt-only-ct-1', + ciphertext: Buffer.from('nope', 'utf8').toString('base64'), + }), + timeoutMs: 15_000, + }); + expect(rejectEncrypted.status).toBe(400); + expect(rejectEncrypted.data?.error).toBe('Invalid parameters'); + + const localId = 'm-pt-only-plain-1'; + const okPlain = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': localId, + }, + body: JSON.stringify({ + localId, + content: { + t: 'plain', + v: { role: 'user', content: { type: 'text', text: 'hello plaintext_only' } }, + }, + }), + timeoutMs: 15_000, + }); + expect(okPlain.status).toBe(200); + expect(okPlain.data?.didWrite).toBe(true); + + const messages = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/messages?limit=10`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(messages.status).toBe(200); + const first = messages.data?.messages?.[0]; + expect(first?.content?.t).toBe('plain'); + expect(first?.content?.v?.content?.text).toBe('hello plaintext_only'); + }, 180_000); +}); + diff --git a/packages/tests/suites/core-e2e/encryption.plaintextStorage.publicShare.roundtrip.feat.encryption.plaintextStorage.feat.sharing.public.e2e.test.ts b/packages/tests/suites/core-e2e/encryption.plaintextStorage.publicShare.roundtrip.feat.encryption.plaintextStorage.feat.sharing.public.e2e.test.ts new file mode 100644 index 000000000..7fd480fe5 --- /dev/null +++ b/packages/tests/suites/core-e2e/encryption.plaintextStorage.publicShare.roundtrip.feat.encryption.plaintextStorage.feat.sharing.public.e2e.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: plaintext public share roundtrip', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('creates a public share without encryptedDataKey for plaintext sessions and reads plain messages', async () => { + const testDir = run.testDir('encryption-plaintext-public-share-roundtrip'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT: '1', + }, + }); + + const auth = await createTestAuth(server.baseUrl); + + const patchMode = await fetchJson<any>(`${server.baseUrl}/v1/account/encryption`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ mode: 'plain' }), + timeoutMs: 15_000, + }); + expect(patchMode.status).toBe(200); + expect(patchMode.data?.mode).toBe('plain'); + + const create = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-plaintext-public-share', + metadata: JSON.stringify({ v: 1, path: '/tmp', flavor: 'claude' }), + agentState: null, + dataEncryptionKey: null, + }), + timeoutMs: 15_000, + }); + expect(create.status).toBe(200); + const sessionId = create.data?.session?.id; + expect(typeof sessionId).toBe('string'); + + const localId = 'm-local-ps-1'; + const commit = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': localId, + }, + body: JSON.stringify({ + localId, + content: { + t: 'plain', + v: { role: 'user', content: { type: 'text', text: 'hello public' } }, + }, + }), + timeoutMs: 15_000, + }); + expect(commit.status).toBe(200); + expect(commit.data?.didWrite).toBe(true); + + const token = 'tok_plain_share_1'; + const createShare = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/public-share`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token, isConsentRequired: false }), + timeoutMs: 15_000, + }); + expect(createShare.status).toBe(200); + expect(createShare.data?.publicShare?.token).toBe(token); + + const access = await fetchJson<any>(`${server.baseUrl}/v1/public-share/${encodeURIComponent(token)}`, { + timeoutMs: 15_000, + }); + expect(access.status).toBe(200); + expect(access.data?.session?.id).toBe(sessionId); + expect(access.data?.session?.encryptionMode).toBe('plain'); + expect(access.data?.encryptedDataKey).toBe(null); + + const publicMessages = await fetchJson<any>(`${server.baseUrl}/v1/public-share/${encodeURIComponent(token)}/messages`, { + timeoutMs: 15_000, + }); + expect(publicMessages.status).toBe(200); + const first = publicMessages.data?.messages?.[0]; + expect(first?.content?.t).toBe('plain'); + expect(first?.content?.v?.content?.text).toBe('hello public'); + }, 180_000); +}); + diff --git a/packages/tests/suites/core-e2e/encryption.plaintextStorage.roundtrip.feat.encryption.plaintextStorage.e2e.test.ts b/packages/tests/suites/core-e2e/encryption.plaintextStorage.roundtrip.feat.encryption.plaintextStorage.e2e.test.ts new file mode 100644 index 000000000..0d79c0fa7 --- /dev/null +++ b/packages/tests/suites/core-e2e/encryption.plaintextStorage.roundtrip.feat.encryption.plaintextStorage.e2e.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: encryption plaintext storage roundtrip', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('stores plain message envelopes when policy is optional and account mode is plain', async () => { + const testDir = run.testDir('encryption-plaintext-storage-roundtrip'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT: '1', + }, + }); + + const auth = await createTestAuth(server.baseUrl); + + const patchMode = await fetchJson<any>(`${server.baseUrl}/v1/account/encryption`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ mode: 'plain' }), + timeoutMs: 15_000, + }); + expect(patchMode.status).toBe(200); + expect(patchMode.data?.mode).toBe('plain'); + + const create = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-plaintext', + metadata: JSON.stringify({ v: 1, path: '/tmp', flavor: 'claude' }), + agentState: null, + dataEncryptionKey: null, + }), + timeoutMs: 15_000, + }); + expect(create.status).toBe(200); + const sessionId = create.data?.session?.id; + expect(typeof sessionId).toBe('string'); + + const localId = 'm-local-1'; + const commit = await fetchJson<any>(`${server.baseUrl}/v2/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': localId, + }, + body: JSON.stringify({ + localId, + content: { + t: 'plain', + v: { role: 'user', content: { type: 'text', text: 'hello' } }, + }, + }), + timeoutMs: 15_000, + }); + expect(commit.status).toBe(200); + expect(commit.data?.didWrite).toBe(true); + + const messages = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/messages?limit=10`, { + headers: { Authorization: `Bearer ${auth.token}` }, + timeoutMs: 15_000, + }); + expect(messages.status).toBe(200); + const first = messages.data?.messages?.[0]; + expect(first?.content?.t).toBe('plain'); + expect(first?.content?.v?.content?.text).toBe('hello'); + }, 180_000); +}); + diff --git a/packages/tests/suites/core-e2e/fakeClaude.streamJsonInput.test.ts b/packages/tests/suites/core-e2e/fakeClaude.streamJsonInput.test.ts new file mode 100644 index 000000000..6df3310b2 --- /dev/null +++ b/packages/tests/suites/core-e2e/fakeClaude.streamJsonInput.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +function parseJsonLines(raw: string): any[] { + return raw + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)]; + } catch { + return []; + } + }); +} + +describe('fake Claude CLI fixture', () => { + it('acknowledges control_request messages with a control_response', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happier-fake-claude-control-')); + try { + const logPath = join(dir, 'fake-claude.jsonl'); + const fixturePath = resolve(process.cwd(), 'src/fixtures/fake-claude-code-cli.cjs'); + + const input = [ + JSON.stringify({ type: 'control_request', request_id: 'req-1', request: { subtype: 'initialize' } }), + JSON.stringify({ type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'hello' }] } }), + ].join('\n'); + + const res = spawnSync(process.execPath, [fixturePath, '--output-format', 'stream-json', '--input-format', 'stream-json'], { + cwd: dir, + env: { + ...process.env, + HAPPIER_E2E_FAKE_CLAUDE_LOG: logPath, + HAPPIER_E2E_FAKE_CLAUDE_INVOCATION_ID: 'inv-1', + HAPPIER_E2E_FAKE_CLAUDE_SESSION_ID: 'session-1', + }, + input: `${input}\n`, + encoding: 'utf8', + }); + + expect(res.status).toBe(0); + + const rows = parseJsonLines(res.stdout); + const response = rows.find((row) => row?.type === 'control_response'); + expect(response?.response?.subtype).toBe('success'); + expect(response?.response?.request_id).toBe('req-1'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('responds to role=user messages even when message type differs', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happier-fake-claude-stream-')); + try { + const logPath = join(dir, 'fake-claude.jsonl'); + const fixturePath = resolve(process.cwd(), 'src/fixtures/fake-claude-code-cli.cjs'); + + const input = JSON.stringify({ + type: 'message', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + }); + + const res = spawnSync(process.execPath, [fixturePath, '--output-format', 'stream-json', '--input-format', 'stream-json'], { + cwd: dir, + env: { + ...process.env, + HAPPIER_E2E_FAKE_CLAUDE_LOG: logPath, + HAPPIER_E2E_FAKE_CLAUDE_INVOCATION_ID: 'inv-1', + HAPPIER_E2E_FAKE_CLAUDE_SESSION_ID: 'session-1', + }, + input: `${input}\n`, + encoding: 'utf8', + }); + + expect(res.status).toBe(0); + + const rows = parseJsonLines(res.stdout); + const assistant = rows.find((row) => row?.type === 'assistant'); + expect(assistant?.message?.content?.[0]?.text).toBe('FAKE_CLAUDE_OK_1'); + + const logRaw = await readFile(logPath, 'utf8'); + expect(parseJsonLines(logRaw).some((row) => row?.type === 'invocation')).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/tests/suites/core-e2e/sharing.public.e2ee.encryptedDataKeyRequired.feat.sharing.public.feat.sharing.contentKeys.e2e.test.ts b/packages/tests/suites/core-e2e/sharing.public.e2ee.encryptedDataKeyRequired.feat.sharing.public.feat.sharing.contentKeys.e2e.test.ts new file mode 100644 index 000000000..dae61b531 --- /dev/null +++ b/packages/tests/suites/core-e2e/sharing.public.e2ee.encryptedDataKeyRequired.feat.sharing.public.feat.sharing.contentKeys.e2e.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +function makeEncryptedDataKeyV0Base64(): string { + const bytes = Buffer.alloc(1 + 32 + 24 + 16, 1); + bytes[0] = 0; + return bytes.toString('base64'); +} + +describe('core e2e: e2ee public share requires encryptedDataKey', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('rejects missing encryptedDataKey for e2ee public shares and returns encryptedDataKey for valid shares', async () => { + const testDir = run.testDir('sharing-public-e2ee-encrypted-datakey-required'); + server = await startServerLight({ testDir }); + + const auth = await createTestAuth(server.baseUrl); + + const create = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-public-share-e2ee', + encryptionMode: 'e2ee', + metadata: Buffer.from('cipher-meta', 'utf8').toString('base64'), + agentState: null, + dataEncryptionKey: Buffer.from('test-data-key', 'utf8').toString('base64'), + }), + timeoutMs: 15_000, + }); + expect(create.status).toBe(200); + const sessionId = create.data?.session?.id; + expect(typeof sessionId).toBe('string'); + expect(create.data?.session?.encryptionMode).toBe('e2ee'); + + const token = 'tok_e2ee_share_1'; + const missing = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/public-share`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token, isConsentRequired: false }), + timeoutMs: 15_000, + }); + expect(missing.status).toBe(400); + expect(typeof missing.data?.error).toBe('string'); + + const createShare = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/public-share`, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token, encryptedDataKey: makeEncryptedDataKeyV0Base64(), isConsentRequired: false }), + timeoutMs: 15_000, + }); + expect(createShare.status).toBe(200); + expect(createShare.data?.publicShare?.token).toBe(token); + + const access = await fetchJson<any>(`${server.baseUrl}/v1/public-share/${encodeURIComponent(token)}`, { + timeoutMs: 15_000, + }); + expect(access.status).toBe(200); + expect(access.data?.session?.id).toBe(sessionId); + expect(access.data?.session?.encryptionMode).toBe('e2ee'); + expect(typeof access.data?.encryptedDataKey).toBe('string'); + }, 180_000); +}); + diff --git a/packages/tests/suites/core-e2e/sharing.session.e2ee.encryptedDataKeyRequired.feat.sharing.session.feat.sharing.contentKeys.feat.social.friends.e2e.test.ts b/packages/tests/suites/core-e2e/sharing.session.e2ee.encryptedDataKeyRequired.feat.sharing.session.feat.sharing.contentKeys.feat.social.friends.e2e.test.ts new file mode 100644 index 000000000..3d21237f8 --- /dev/null +++ b/packages/tests/suites/core-e2e/sharing.session.e2ee.encryptedDataKeyRequired.feat.sharing.session.feat.sharing.contentKeys.feat.social.friends.e2e.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { createTestAuth } from '../../src/testkit/auth'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; +import { addFriend, fetchAccountId, setUsername } from '../../src/testkit/socialFriends'; + +const run = createRunDirs({ runLabel: 'core' }); + +function makeEncryptedDataKeyV0Base64(): string { + const bytes = Buffer.alloc(1 + 32 + 24 + 16, 1); + bytes[0] = 0; + return bytes.toString('base64'); +} + +describe('core e2e: e2ee direct share requires encryptedDataKey', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('rejects missing/invalid encryptedDataKey for e2ee sessions and accepts a valid v0 envelope', async () => { + const testDir = run.testDir('sharing-session-e2ee-encrypted-datakey-required'); + server = await startServerLight({ + testDir, + extraEnv: { + HAPPIER_FEATURE_SOCIAL_FRIENDS__ENABLED: '1', + HAPPIER_FEATURE_SOCIAL_FRIENDS__ALLOW_USERNAME: '1', + }, + }); + + const owner = await createTestAuth(server.baseUrl); + const recipient = await createTestAuth(server.baseUrl); + + const ownerId = await fetchAccountId(server.baseUrl, owner.token); + const recipientId = await fetchAccountId(server.baseUrl, recipient.token); + + await setUsername(server.baseUrl, owner.token, 'owner_e2ee_share'); + await setUsername(server.baseUrl, recipient.token, 'recipient_e2ee_share'); + await addFriend(server.baseUrl, owner.token, recipientId); + await addFriend(server.baseUrl, recipient.token, ownerId); + + const create = await fetchJson<any>(`${server.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${owner.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag: 'e2e-share-e2ee', + encryptionMode: 'e2ee', + metadata: Buffer.from('cipher-meta', 'utf8').toString('base64'), + agentState: null, + dataEncryptionKey: Buffer.from('test-data-key', 'utf8').toString('base64'), + }), + timeoutMs: 15_000, + }); + expect(create.status).toBe(200); + const sessionId = create.data?.session?.id; + expect(typeof sessionId).toBe('string'); + expect(create.data?.session?.encryptionMode).toBe('e2ee'); + + const missing = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/shares`, { + method: 'POST', + headers: { + Authorization: `Bearer ${owner.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId: recipientId, accessLevel: 'view' }), + timeoutMs: 15_000, + }); + expect(missing.status).toBe(400); + expect(missing.data?.error).toBe('encryptedDataKey required'); + + const invalid = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/shares`, { + method: 'POST', + headers: { + Authorization: `Bearer ${owner.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId: recipientId, accessLevel: 'view', encryptedDataKey: Buffer.from('x').toString('base64') }), + timeoutMs: 15_000, + }); + expect(invalid.status).toBe(400); + expect(invalid.data?.error).toBe('Invalid encryptedDataKey'); + + const ok = await fetchJson<any>(`${server.baseUrl}/v1/sessions/${sessionId}/shares`, { + method: 'POST', + headers: { + Authorization: `Bearer ${owner.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId: recipientId, + accessLevel: 'view', + encryptedDataKey: makeEncryptedDataKeyV0Base64(), + }), + timeoutMs: 15_000, + }); + expect(ok.status).toBe(200); + expect(typeof ok.data?.share?.id).toBe('string'); + }, 180_000); +}); + diff --git a/packages/tests/suites/providers/runExtendedDbDocker.script.test.ts b/packages/tests/suites/providers/runExtendedDbDocker.script.test.ts index 035112aee..38046ba5c 100644 --- a/packages/tests/suites/providers/runExtendedDbDocker.script.test.ts +++ b/packages/tests/suites/providers/runExtendedDbDocker.script.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { parseArgs, resolveExtendedDbCommandTimeoutMs } from '../../scripts/run-extended-db-docker.mjs'; +import { parseArgs, resolveExtendedDbCommandTimeoutMs, resolveExtendedDbStepTimeoutMs } from '../../scripts/run-extended-db-docker.mjs'; describe('extended-db docker script args', () => { it('parses valid args', () => { @@ -32,6 +32,13 @@ describe('extended-db docker script args', () => { }); describe('extended-db docker script timeouts', () => { + it('uses a generous default step timeout (overrideable by env)', () => { + expect(resolveExtendedDbStepTimeoutMs({} as unknown as NodeJS.ProcessEnv)).toBe(3_600_000); + expect( + resolveExtendedDbStepTimeoutMs({ HAPPIER_E2E_EXTENDED_DB_STEP_TIMEOUT_MS: '120000' } as unknown as NodeJS.ProcessEnv), + ).toBe(120_000); + }); + it('uses fallback for missing/invalid values', () => { expect(resolveExtendedDbCommandTimeoutMs(undefined, 55_000)).toBe(55_000); expect(resolveExtendedDbCommandTimeoutMs('0', 55_000)).toBe(55_000); diff --git a/packages/tests/suites/ui-e2e/auth.mtls.autoRedirect.spec.ts b/packages/tests/suites/ui-e2e/auth.mtls.autoRedirect.spec.ts new file mode 100644 index 000000000..f0ee1b94e --- /dev/null +++ b/packages/tests/suites/ui-e2e/auth.mtls.autoRedirect.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; +import { startUiWeb, type StartedUiWeb } from '../../src/testkit/process/uiWeb'; +import { gotoDomContentLoadedWithRetries, normalizeLoopbackBaseUrl } from '../../src/testkit/uiE2e/pageNavigation'; +import { startForwardedHeaderProxy } from '../../src/testkit/uiE2e/forwardedHeaderProxy'; + +const run = createRunDirs({ runLabel: 'ui-e2e' }); + +test.describe('ui e2e: mTLS auto-redirect', () => { + test.describe.configure({ mode: 'serial' }); + + const suiteDir = run.testDir('auth-mtls-auto-redirect-suite'); + + let server: StartedServer | null = null; + let ui: StartedUiWeb | null = null; + let uiBaseUrl: string | null = null; + let proxyBaseUrl: string | null = null; + let proxyStop: (() => Promise<void>) | null = null; + + function resolveServerLightSqliteDbPath(params: { server: StartedServer }): string { + return resolve(join(params.server.dataDir, 'happier-server-light.sqlite')); + } + + test.beforeAll(async () => { + test.setTimeout(600_000); + await mkdir(suiteDir, { recursive: true }); + + server = await startServerLight({ + testDir: suiteDir, + dbProvider: 'sqlite', + extraEnv: { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: '0', + AUTH_ANONYMOUS_SIGNUP_ENABLED: '0', + AUTH_SIGNUP_PROVIDERS: '', + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: '1', + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: 'plain', + + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: '1', + HAPPIER_FEATURE_AUTH_MTLS__MODE: 'forwarded', + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: '1', + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: '1', + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: 'san_email', + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: 'example.com', + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_EMAIL_HEADER: 'x-happier-client-cert-email', + HAPPIER_FEATURE_AUTH_MTLS__FORWARDED_FINGERPRINT_HEADER: 'x-happier-client-cert-sha256', + + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED: '1', + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_PROVIDER_ID: 'mtls', + }, + }); + + const proxy = await startForwardedHeaderProxy({ + targetBaseUrl: server.baseUrl, + identityHeaders: { + 'x-happier-client-cert-email': 'alice@example.com', + 'x-happier-client-cert-sha256': 'sha256:abc123', + }, + }); + proxyBaseUrl = proxy.baseUrl; + proxyStop = proxy.stop; + + ui = await startUiWeb({ + testDir: suiteDir, + env: { + ...process.env, + EXPO_PUBLIC_DEBUG: '1', + EXPO_PUBLIC_HAPPY_SERVER_URL: proxy.baseUrl, + EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: `e2e-${run.runId}`, + }, + }); + uiBaseUrl = normalizeLoopbackBaseUrl(ui.baseUrl); + }); + + test.afterAll(async () => { + test.setTimeout(120_000); + await ui?.stop().catch(() => {}); + await proxyStop?.().catch(() => {}); + await server?.stop().catch(() => {}); + }); + + test('auto-redirects and logs in via forwarded mTLS', async ({ page }) => { + test.setTimeout(300_000); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!server) throw new Error('missing server'); + if (!proxyBaseUrl) throw new Error('missing proxy base url'); + + const mtlsOk = page.waitForResponse( + (resp) => resp.url().startsWith(`${proxyBaseUrl}/v1/auth/mtls`) && resp.status() === 200, + { timeout: 120_000 }, + ); + + await gotoDomContentLoadedWithRetries(page, uiBaseUrl); + + await expect(page.getByTestId('welcome-create-account')).toHaveCount(0, { timeout: 120_000 }); + await expect(page.getByTestId('session-getting-started-kind-connect_machine')).toHaveCount(1, { timeout: 120_000 }); + + await mtlsOk; + + const dbPath = resolveServerLightSqliteDbPath({ server }); + const raw = execFileSync( + 'sqlite3', + ['-json', dbPath, "select count(1) as n from AccountIdentity where provider = 'mtls';"], + { encoding: 'utf8' }, + ); + const parsed = JSON.parse(raw) as Array<{ n?: unknown }>; + const n = parsed?.[0]?.n; + expect(n === 1 || n === '1').toBe(true); + }); +}); diff --git a/packages/tests/suites/ui-e2e/auth.mtls.terminalConnect.daemon.spec.ts b/packages/tests/suites/ui-e2e/auth.mtls.terminalConnect.daemon.spec.ts new file mode 100644 index 000000000..0c2c3a210 --- /dev/null +++ b/packages/tests/suites/ui-e2e/auth.mtls.terminalConnect.daemon.spec.ts @@ -0,0 +1,180 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; +import { startUiWeb, type StartedUiWeb } from '../../src/testkit/process/uiWeb'; +import { startTestDaemon, type StartedDaemon } from '../../src/testkit/daemon/daemon'; +import { startCliAuthLoginForTerminalConnect, type StartedCliTerminalConnect } from '../../src/testkit/uiE2e/cliTerminalConnect'; +import { fakeClaudeFixturePath } from '../../src/testkit/fakeClaude'; +import { gotoDomContentLoadedWithRetries, normalizeLoopbackBaseUrl } from '../../src/testkit/uiE2e/pageNavigation'; +import { startForwardedHeaderProxy } from '../../src/testkit/uiE2e/forwardedHeaderProxy'; + +const run = createRunDirs({ runLabel: 'ui-e2e' }); + +test.describe('ui e2e: mTLS login + terminal connect', () => { + test.describe.configure({ mode: 'serial' }); + + const suiteDir = run.testDir('auth-mtls-terminal-connect-suite'); + const cliHomeDir = resolve(join(suiteDir, 'cli-home')); + + let server: StartedServer | null = null; + let ui: StartedUiWeb | null = null; + let uiBaseUrl: string | null = null; + let proxyBaseUrl: string | null = null; + let proxyStop: (() => Promise<void>) | null = null; + let daemon: StartedDaemon | null = null; + + async function waitForWelcomeAuthenticated(page: Page, baseUrl: string): Promise<void> { + await gotoDomContentLoadedWithRetries(page, baseUrl); + await expect(page.getByTestId('welcome-create-account')).toHaveCount(0, { timeout: 120_000 }); + await expect(page.getByTestId('session-getting-started-kind-connect_machine')).toHaveCount(1, { timeout: 120_000 }); + } + + test.beforeAll(async () => { + test.setTimeout(600_000); + await mkdir(cliHomeDir, { recursive: true }); + + server = await startServerLight({ + testDir: suiteDir, + dbProvider: 'sqlite', + extraEnv: { + HAPPIER_BUILD_FEATURES_DENY: 'sharing.contentKeys', + + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: '0', + AUTH_ANONYMOUS_SIGNUP_ENABLED: '0', + AUTH_SIGNUP_PROVIDERS: '', + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: '1', + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: 'plain', + + HAPPIER_FEATURE_AUTH_MTLS__ENABLED: '1', + HAPPIER_FEATURE_AUTH_MTLS__MODE: 'forwarded', + HAPPIER_FEATURE_AUTH_MTLS__TRUST_FORWARDED_HEADERS: '1', + HAPPIER_FEATURE_AUTH_MTLS__AUTO_PROVISION: '1', + HAPPIER_FEATURE_AUTH_MTLS__IDENTITY_SOURCE: 'san_email', + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_EMAIL_DOMAINS: 'example.com', + HAPPIER_FEATURE_AUTH_MTLS__ALLOWED_ISSUERS: 'CN=Example Root CA', + + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED: '1', + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_PROVIDER_ID: 'mtls', + + HAPPIER_PRESENCE_SESSION_TIMEOUT_MS: '60000', + HAPPIER_PRESENCE_MACHINE_TIMEOUT_MS: '60000', + HAPPIER_PRESENCE_TIMEOUT_TICK_MS: '1000', + }, + }); + + const proxy = await startForwardedHeaderProxy({ + targetBaseUrl: server.baseUrl, + identityHeaders: { + 'x-happier-client-cert-email': 'alice@example.com', + 'x-happier-client-cert-issuer': 'CN=Example Root CA', + 'x-happier-client-cert-sha256': 'sha256:abc123', + }, + }); + proxyBaseUrl = proxy.baseUrl; + proxyStop = proxy.stop; + + ui = await startUiWeb({ + testDir: suiteDir, + env: { + ...process.env, + EXPO_PUBLIC_DEBUG: '1', + EXPO_PUBLIC_HAPPY_SERVER_URL: proxy.baseUrl, + EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: `e2e-${run.runId}`, + }, + }); + uiBaseUrl = normalizeLoopbackBaseUrl(ui.baseUrl); + }); + + test.afterAll(async () => { + test.setTimeout(120_000); + await daemon?.stop().catch(() => {}); + await ui?.stop().catch(() => {}); + await proxyStop?.().catch(() => {}); + await server?.stop().catch(() => {}); + }); + + test('logs in via mTLS, approves terminal connect, and daemon becomes online', async ({ page }) => { + test.setTimeout(600_000); + if (!server) throw new Error('missing server fixture'); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!proxyBaseUrl) throw new Error('missing proxy base url'); + + const mtlsOk = page.waitForResponse( + (resp) => resp.url().startsWith(`${proxyBaseUrl}/v1/auth/mtls`) && resp.request().method() === 'POST' && resp.status() === 200, + { timeout: 120_000 }, + ); + + await waitForWelcomeAuthenticated(page, uiBaseUrl); + await mtlsOk; + + const testDir = resolve(join(suiteDir, 't1-mtls-terminal-connect')); + await mkdir(testDir, { recursive: true }); + + let cliLogin: StartedCliTerminalConnect | null = null; + try { + cliLogin = await startCliAuthLoginForTerminalConnect({ + testDir, + cliHomeDir, + // Keep the CLI terminal-connect "server" param aligned with the UI's active server URL + // (which is the forwarded-header proxy) so the web app doesn't switch servers mid-flow. + serverUrl: proxyBaseUrl, + webappUrl: uiBaseUrl, + env: { + ...process.env, + CI: '1', + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + }, + }); + + await page.goto(cliLogin.connectUrl, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('terminal-connect-approve')).toHaveCount(1, { timeout: 60_000 }); + await page.getByTestId('terminal-connect-approve').click(); + await cliLogin.waitForSuccess(); + + const fakeClaudeLogPath = resolve(join(testDir, 'fake-claude.jsonl')); + const fakeClaudePath = fakeClaudeFixturePath(); + + daemon = await startTestDaemon({ + testDir, + happyHomeDir: cliHomeDir, + env: { + ...process.env, + CI: '1', + HAPPIER_HOME_DIR: cliHomeDir, + // Use the same server URL the CLI authenticated against so the daemon can find credentials. + // This is the forwarded-header proxy; it forwards to the real server. + HAPPIER_SERVER_URL: proxyBaseUrl, + HAPPIER_WEBAPP_URL: uiBaseUrl, + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + HAPPIER_CLAUDE_PATH: fakeClaudePath, + HAPPIER_E2E_FAKE_CLAUDE_LOG: fakeClaudeLogPath, + HAPPIER_E2E_FAKE_CLAUDE_SESSION_ID: `fake-claude-session-${run.runId}`, + HAPPIER_E2E_FAKE_CLAUDE_INVOCATION_ID: `fake-claude-invocation-${run.runId}`, + }, + }); + + await page.goto(`${uiBaseUrl}/`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('session-getting-started-kind-start_daemon')).toHaveCount(0, { timeout: 180_000 }); + + await expect + .poll( + async () => { + const createCount = await page.getByTestId('session-getting-started-kind-create_session').count(); + const selectCount = await page.getByTestId('session-getting-started-kind-select_session').count(); + return createCount > 0 || selectCount > 0; + }, + { timeout: 180_000 }, + ) + .toBe(true); + } finally { + await cliLogin?.stop().catch(() => {}); + } + }); +}); diff --git a/packages/tests/suites/ui-e2e/auth.oauth.keyed.github.restore.lostAccess.spec.ts b/packages/tests/suites/ui-e2e/auth.oauth.keyed.github.restore.lostAccess.spec.ts new file mode 100644 index 000000000..51c0ec2d6 --- /dev/null +++ b/packages/tests/suites/ui-e2e/auth.oauth.keyed.github.restore.lostAccess.spec.ts @@ -0,0 +1,248 @@ +import { test, expect } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; +import { startUiWeb, type StartedUiWeb } from '../../src/testkit/process/uiWeb'; +import { gotoDomContentLoadedWithRetries, normalizeLoopbackBaseUrl } from '../../src/testkit/uiE2e/pageNavigation'; +import { reserveAvailablePort } from '../../src/testkit/network/reserveAvailablePort'; +import { startFakeGitHubOAuthServer, type StopFn } from '../../src/testkit/oauth/fakeGithubOAuthServer'; + +const run = createRunDirs({ runLabel: 'ui-e2e' }); + +type GithubOAuthHarness = Readonly<{ + baseUrl: string; + stop: StopFn; + getCounts: () => Readonly<Record<string, number>>; +}>; + +function resolveServerLightSqliteDbPath(params: { server: StartedServer }): string { + return resolve(join(params.server.dataDir, 'happier-server-light.sqlite')); +} + +function querySqliteJson(dbPath: string, sql: string): unknown { + const raw = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }); + return JSON.parse(raw) as unknown; +} + +async function readKeyedSecretFromLocalStorage(page: { evaluate: <T>(fn: () => T | Promise<T>) => Promise<T> }): Promise<string> { + const secrets = await page.evaluate(() => { + const safeParse = (raw: string) => { + try { + return JSON.parse(raw); + } catch { + return null; + } + }; + + const found: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key) continue; + if (!key.startsWith('auth_credentials__srv_')) continue; + const value = localStorage.getItem(key); + if (typeof value !== 'string' || !value) continue; + const parsed: any = safeParse(value); + if (!parsed || typeof parsed !== 'object') continue; + if (typeof parsed.secret === 'string' && parsed.secret.trim().length > 0) { + found.push(parsed.secret.trim()); + } + } + return found; + }); + + if (!Array.isArray(secrets) || secrets.length === 0) { + throw new Error('missing keyed secret in localStorage'); + } + // Most-recent is last-write wins; in practice only one should exist. + return secrets[secrets.length - 1]!; +} + +test.describe('ui e2e: keyed GitHub OAuth restore + lost access', () => { + test.describe.configure({ mode: 'serial' }); + + const suiteDir = run.testDir('auth-oauth-keyed-github-restore-lost-access-suite'); + + let server: StartedServer | null = null; + let ui: StartedUiWeb | null = null; + let uiBaseUrl: string | null = null; + let oauth: GithubOAuthHarness | null = null; + + test.beforeAll(async () => { + test.setTimeout(600_000); + await mkdir(suiteDir, { recursive: true }); + + oauth = await startFakeGitHubOAuthServer(); + + const uiPort = await reserveAvailablePort(); + const uiReturnBaseUrl = `http://127.0.0.1:${uiPort}`; + + const serverPort = await reserveAvailablePort(); + const serverBaseUrl = `http://127.0.0.1:${serverPort}`; + + server = await startServerLight({ + testDir: suiteDir, + dbProvider: 'sqlite', + __portAllocator: async () => serverPort, + extraEnv: { + AUTH_ANONYMOUS_SIGNUP_ENABLED: '0', + AUTH_SIGNUP_PROVIDERS: 'github', + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: '0', + HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED: '0', + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED: '0', + + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'required_e2ee', + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: 'e2ee', + + HAPPIER_FEATURE_AUTH_RECOVERY__PROVIDER_RESET_ENABLED: '1', + + GITHUB_CLIENT_ID: 'gh_client', + GITHUB_CLIENT_SECRET: 'gh_secret', + GITHUB_REDIRECT_URL: `${serverBaseUrl}/v1/oauth/github/callback`, + HAPPIER_WEBAPP_URL: uiReturnBaseUrl, + + GITHUB_OAUTH_AUTHORIZE_URL: `${oauth.baseUrl}/login/oauth/authorize`, + GITHUB_OAUTH_TOKEN_URL: `${oauth.baseUrl}/login/oauth/access_token`, + GITHUB_API_USER_URL: `${oauth.baseUrl}/user`, + }, + }); + + ui = await startUiWeb({ + testDir: suiteDir, + port: uiPort, + env: { + ...process.env, + EXPO_PUBLIC_DEBUG: '1', + EXPO_PUBLIC_HAPPY_SERVER_URL: server.baseUrl, + EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: `e2e-${run.runId}`, + }, + }); + + uiBaseUrl = normalizeLoopbackBaseUrl(ui.baseUrl); + }); + + test.afterAll(async () => { + test.setTimeout(120_000); + await ui?.stop().catch(() => {}); + await server?.stop().catch(() => {}); + await oauth?.stop().catch(() => {}); + }); + + test('signs up with keyed GitHub OAuth and requires restore on another browser', async ({ page, browser }) => { + test.setTimeout(300_000); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!server) throw new Error('missing server'); + if (!oauth) throw new Error('missing oauth'); + + const finalizedFirst = page.waitForResponse( + (resp) => resp.url().startsWith(`${server.baseUrl}/v1/auth/external/github/finalize`) && resp.status() === 200, + { timeout: 120_000 }, + ); + + await gotoDomContentLoadedWithRetries(page, uiBaseUrl); + await page.getByTestId('welcome-signup-provider').click(); + + await finalizedFirst; + await expect.poll(() => new URL(page.url()).pathname, { timeout: 120_000 }).toBe('/'); + await expect + .poll(async () => await page.getByTestId('session-getting-started-kind-connect_machine').count(), { timeout: 120_000 }) + .toBeGreaterThan(0); + + const secret = await readKeyedSecretFromLocalStorage(page); + + const dbPath = resolveServerLightSqliteDbPath({ server }); + const identityRows = querySqliteJson( + dbPath, + "select count(1) as n from AccountIdentity where provider = 'github';", + ) as Array<{ n?: unknown }>; + expect(identityRows?.[0]?.n === 1 || identityRows?.[0]?.n === '1').toBe(true); + + const keyedAccountRows = querySqliteJson( + dbPath, + "select count(1) as n from Account where publicKey is not null;", + ) as Array<{ n?: unknown }>; + expect(keyedAccountRows?.[0]?.n === 1 || keyedAccountRows?.[0]?.n === '1').toBe(true); + + const ctx2 = await browser.newContext(); + const page2 = await ctx2.newPage(); + try { + const finalizedSecond = page2.waitForResponse( + (resp) => resp.url().startsWith(`${server.baseUrl}/v1/auth/external/github/finalize`) && resp.status() === 409, + { timeout: 120_000 }, + ); + + await gotoDomContentLoadedWithRetries(page2, uiBaseUrl); + await page2.getByTestId('welcome-signup-provider').click(); + + await finalizedSecond; + await expect.poll(() => new URL(page2.url()).pathname, { timeout: 120_000 }).toBe('/restore'); + + await page2.getByTestId('restore-open-manual').click(); + await page2.getByTestId('restore-manual-secret-input').fill(secret); + const authOk = page2.waitForResponse((resp) => resp.url().endsWith('/v1/auth') && resp.status() === 200, { timeout: 120_000 }); + await page2.getByTestId('restore-manual-submit').click(); + await authOk; + + await expect(page2.getByTestId('restore-manual-secret-input')).toHaveCount(0, { timeout: 120_000 }); + await expect + .poll(async () => await page2.getByTestId('main-header-start-new-session').count(), { timeout: 120_000 }) + .toBeGreaterThan(0); + } finally { + await ctx2.close(); + } + + const counts = oauth.getCounts(); + expect((counts.authorize ?? 0) > 0).toBe(true); + expect((counts.token ?? 0) > 0).toBe(true); + expect((counts.user ?? 0) > 0).toBe(true); + }); + + test('supports provider reset (lost access) via GitHub OAuth', async ({ browser }) => { + test.setTimeout(300_000); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!server) throw new Error('missing server'); + + const ctx = await browser.newContext(); + const p = await ctx.newPage(); + try { + const initialFinalize = p.waitForResponse( + (resp) => resp.url().startsWith(`${server.baseUrl}/v1/auth/external/github/finalize`) && resp.status() === 409, + { timeout: 120_000 }, + ); + + await gotoDomContentLoadedWithRetries(p, uiBaseUrl); + await p.getByTestId('welcome-signup-provider').click(); + await initialFinalize; + + await expect.poll(() => new URL(p.url()).pathname, { timeout: 120_000 }).toBe('/restore'); + + await expect(p.getByTestId('restore-open-lost-access')).toHaveCount(1, { timeout: 120_000 }); + await p.getByTestId('restore-open-lost-access').click(); + + await expect.poll(() => new URL(p.url()).pathname, { timeout: 120_000 }).toBe('/restore/lost-access'); + await expect(p.getByTestId('lost-access-provider-github')).toHaveCount(1, { timeout: 120_000 }); + await p.getByTestId('lost-access-provider-github').click(); + + const resetFinalize = p.waitForResponse( + (resp) => resp.url().startsWith(`${server.baseUrl}/v1/auth/external/github/finalize`) && resp.status() === 200, + { timeout: 120_000 }, + ); + await expect(p.getByTestId('web-modal-confirm')).toHaveCount(1, { timeout: 120_000 }); + await p.getByTestId('web-modal-confirm').click({ timeout: 120_000 }); + await resetFinalize; + + await expect + .poll(async () => await p.getByTestId('main-header-start-new-session').count(), { timeout: 120_000 }) + .toBeGreaterThan(0); + } finally { + await ctx.close(); + } + + const dbPath = resolveServerLightSqliteDbPath({ server }); + const accountRows = querySqliteJson(dbPath, 'select count(1) as n from Account;') as Array<{ n?: unknown }>; + expect((accountRows?.[0]?.n as any) === 2 || (accountRows?.[0]?.n as any) === '2').toBe(true); + }); +}); diff --git a/packages/tests/suites/ui-e2e/auth.oauth.keyless.autoRedirect.github.spec.ts b/packages/tests/suites/ui-e2e/auth.oauth.keyless.autoRedirect.github.spec.ts new file mode 100644 index 000000000..d4ce7012b --- /dev/null +++ b/packages/tests/suites/ui-e2e/auth.oauth.keyless.autoRedirect.github.spec.ts @@ -0,0 +1,142 @@ +import { test, expect } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; +import { startUiWeb, type StartedUiWeb } from '../../src/testkit/process/uiWeb'; +import { gotoDomContentLoadedWithRetries, normalizeLoopbackBaseUrl } from '../../src/testkit/uiE2e/pageNavigation'; +import { reserveAvailablePort } from '../../src/testkit/network/reserveAvailablePort'; +import { startFakeGitHubOAuthServer, type StopFn } from '../../src/testkit/oauth/fakeGithubOAuthServer'; + +const run = createRunDirs({ runLabel: 'ui-e2e' }); + +test.describe('ui e2e: keyless OAuth auto-redirect (GitHub)', () => { + test.describe.configure({ mode: 'serial' }); + + const suiteDir = run.testDir('auth-oauth-keyless-auto-redirect-github-suite'); + + let server: StartedServer | null = null; + let ui: StartedUiWeb | null = null; + let uiBaseUrl: string | null = null; + let oauthBaseUrl: string | null = null; + let oauthStop: StopFn | null = null; + let oauthCounts: (() => Readonly<Record<string, number>>) | null = null; + + function resolveServerLightSqliteDbPath(params: { server: StartedServer }): string { + return resolve(join(params.server.dataDir, 'happier-server-light.sqlite')); + } + + test.beforeAll(async () => { + test.setTimeout(600_000); + await mkdir(suiteDir, { recursive: true }); + + const oauth = await startFakeGitHubOAuthServer(); + oauthBaseUrl = oauth.baseUrl; + oauthStop = oauth.stop; + oauthCounts = oauth.getCounts; + + const uiPort = await reserveAvailablePort(); + const uiReturnBaseUrl = `http://127.0.0.1:${uiPort}`; + + const serverPort = await reserveAvailablePort(); + const serverBaseUrl = `http://127.0.0.1:${serverPort}`; + + server = await startServerLight({ + testDir: suiteDir, + dbProvider: 'sqlite', + __portAllocator: async () => serverPort, + extraEnv: { + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: '0', + AUTH_ANONYMOUS_SIGNUP_ENABLED: '0', + AUTH_SIGNUP_PROVIDERS: '', + + HAPPIER_FEATURE_E2EE__KEYLESS_ACCOUNTS_ENABLED: '1', + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__DEFAULT_ACCOUNT_MODE: 'plain', + + HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_ENABLED: '1', + HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_PROVIDERS: 'github', + HAPPIER_FEATURE_AUTH_OAUTH__KEYLESS_AUTO_PROVISION: '1', + + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_ENABLED: '1', + HAPPIER_FEATURE_AUTH_UI__AUTO_REDIRECT_PROVIDER_ID: 'github', + + GITHUB_CLIENT_ID: 'gh_client', + GITHUB_CLIENT_SECRET: 'gh_secret', + GITHUB_REDIRECT_URL: `${serverBaseUrl}/v1/oauth/github/callback`, + HAPPIER_WEBAPP_URL: uiReturnBaseUrl, + + GITHUB_OAUTH_AUTHORIZE_URL: `${oauth.baseUrl}/login/oauth/authorize`, + GITHUB_OAUTH_TOKEN_URL: `${oauth.baseUrl}/login/oauth/access_token`, + GITHUB_API_USER_URL: `${oauth.baseUrl}/user`, + }, + }); + + ui = await startUiWeb({ + testDir: suiteDir, + port: uiPort, + env: { + ...process.env, + EXPO_PUBLIC_DEBUG: '1', + EXPO_PUBLIC_HAPPY_SERVER_URL: server.baseUrl, + EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: `e2e-${run.runId}`, + }, + }); + + uiBaseUrl = normalizeLoopbackBaseUrl(ui.baseUrl); + }); + + test.afterAll(async () => { + test.setTimeout(120_000); + await ui?.stop().catch(() => {}); + await server?.stop().catch(() => {}); + await oauthStop?.().catch(() => {}); + }); + + test('auto-redirects into keyless GitHub login and lands in the app authenticated', async ({ page }) => { + test.setTimeout(300_000); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!server) throw new Error('missing server'); + if (!oauthBaseUrl) throw new Error('missing oauth base url'); + if (!oauthCounts) throw new Error('missing oauth counts'); + + const finalized = page.waitForResponse( + (resp) => resp.url().startsWith(`${server.baseUrl}/v1/auth/external/github/finalize-keyless`) && resp.status() === 200, + { timeout: 120_000 }, + ); + + await gotoDomContentLoadedWithRetries(page, uiBaseUrl); + + // Auto-redirect should run without the user tapping buttons. + await expect(page.getByTestId('welcome-create-account')).toHaveCount(0, { timeout: 120_000 }); + + await finalized; + await expect + .poll(() => new URL(page.url()).pathname, { timeout: 120_000 }) + .toBe('/'); + await expect + .poll(async () => await page.getByTestId('session-getting-started-kind-connect_machine').count(), { timeout: 120_000 }) + .toBeGreaterThan(0); + + const counts = oauthCounts(); + expect((counts.authorize ?? 0) > 0).toBe(true); + expect((counts.token ?? 0) > 0).toBe(true); + expect((counts.user ?? 0) > 0).toBe(true); + + const dbPath = resolveServerLightSqliteDbPath({ server }); + const raw = execFileSync( + 'sqlite3', + [ + '-json', + dbPath, + "select count(1) as n from AccountIdentity where provider = 'github';", + ], + { encoding: 'utf8' }, + ); + const parsed = JSON.parse(raw) as Array<{ n?: unknown }>; + const n = parsed?.[0]?.n; + expect(n === 1 || n === '1').toBe(true); + }); +}); diff --git a/packages/tests/suites/ui-e2e/auth.terminalConnect.daemon.spec.ts b/packages/tests/suites/ui-e2e/auth.terminalConnect.daemon.spec.ts new file mode 100644 index 000000000..596e6c25e --- /dev/null +++ b/packages/tests/suites/ui-e2e/auth.terminalConnect.daemon.spec.ts @@ -0,0 +1,639 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; +import { startUiWeb, type StartedUiWeb } from '../../src/testkit/process/uiWeb'; +import { startTestDaemon, type StartedDaemon } from '../../src/testkit/daemon/daemon'; +import { startCliAuthLoginForTerminalConnect, type StartedCliTerminalConnect } from '../../src/testkit/uiE2e/cliTerminalConnect'; +import { fakeClaudeFixturePath } from '../../src/testkit/fakeClaude'; +import { gotoDomContentLoadedWithRetries, normalizeLoopbackBaseUrl } from '../../src/testkit/uiE2e/pageNavigation'; + +const run = createRunDirs({ runLabel: 'ui-e2e' }); + +test.describe('ui e2e: auth + terminal connect', () => { + test.describe.configure({ mode: 'serial' }); + + const suiteDir = run.testDir('auth-terminal-connect-suite'); + const cliHomeDir = resolve(join(suiteDir, 'cli-home')); + + let server: StartedServer | null = null; + let ui: StartedUiWeb | null = null; + let uiBaseUrl: string | null = null; + let daemon: StartedDaemon | null = null; + let accountSecretKeyFormatted: string | null = null; + let fakeClaudeLogPath: string | null = null; + let createdSessionId: string | null = null; + let fakeClaudePath: string | null = null; + + async function readAccountSecretKeyFromSettings(page: Page, baseUrl: string): Promise<string> { + await page.goto(`${baseUrl}/settings/account`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('settings-account-secret-key-item')).toHaveCount(1, { timeout: 60_000 }); + await page.getByTestId('settings-account-secret-key-item').click(); + await expect(page.getByTestId('settings-account-secret-key-value')).toHaveCount(1, { timeout: 60_000 }); + const value = (await page.getByTestId('settings-account-secret-key-value').innerText()).trim(); + if (!value) throw new Error('settings-account-secret-key-value is empty'); + return value.replace(/\s+/g, ' '); + } + + async function restoreAccountUsingSecretKey( + page: Page, + baseUrl: string, + secretKeyFormatted: string, + options?: { postRestorePath?: string | null }, + ): Promise<void> { + await gotoDomContentLoadedWithRetries(page, baseUrl); + await page.getByTestId('welcome-restore').click(); + + await expect(page.getByTestId('restore-open-manual')).toHaveCount(1, { timeout: 60_000 }); + await page.getByTestId('restore-open-manual').click(); + + await page.getByTestId('restore-manual-secret-input').fill(secretKeyFormatted); + const authOk = page.waitForResponse((resp) => resp.url().endsWith('/v1/auth') && resp.status() === 200, { timeout: 60_000 }); + await page.getByTestId('restore-manual-submit').click(); + await authOk; + + // Restore screen calls router.back() after auth; wait for that navigation to complete before forcing our post-restore path. + await page.waitForURL((url) => !url.pathname.endsWith('/restore/manual'), { timeout: 60_000 }); + + const postRestorePath = options?.postRestorePath; + if (postRestorePath === null) return; + + const path = postRestorePath ?? '/'; + await gotoDomContentLoadedWithRetries(page, `${baseUrl}${path}`); + } + + function resolveServerLightSqliteDbPath(params: { suiteDir: string }): string { + return resolve(join(params.suiteDir, 'server-light-data', 'happier-server-light.sqlite')); + } + + function readLatestMachineIdFromServerLightDb(params: { suiteDir: string }): string { + const dbPath = resolveServerLightSqliteDbPath({ suiteDir: params.suiteDir }); + try { + const raw = execFileSync('sqlite3', ['-json', dbPath, 'select id from Machine order by createdAt desc limit 1;'], { + encoding: 'utf8', + }); + const parsed = JSON.parse(raw) as Array<{ id?: unknown }>; + const id = parsed?.[0]?.id; + if (typeof id === 'string' && id.trim()) return id.trim(); + } catch { + // ignore - pollers can retry + } + throw new Error(`Failed to read machine id from server light sqlite db: ${dbPath}`); + } + + function readMachineActiveFromServerLightDb(params: { suiteDir: string; machineId: string }): boolean | null { + const dbPath = resolveServerLightSqliteDbPath({ suiteDir: params.suiteDir }); + try { + const query = `select active from Machine where id = '${params.machineId.replaceAll("'", "''")}' limit 1;`; + const raw = execFileSync('sqlite3', ['-json', dbPath, query], { encoding: 'utf8' }); + const parsed = JSON.parse(raw) as Array<{ active?: unknown }>; + const active = parsed?.[0]?.active; + if (active === 1 || active === true) return true; + if (active === 0 || active === false) return false; + return null; + } catch { + return null; + } + } + + test.beforeAll(async () => { + test.setTimeout(420_000); + await mkdir(cliHomeDir, { recursive: true }); + + try { + server = await startServerLight({ + testDir: suiteDir, + dbProvider: 'sqlite', + extraEnv: { + // UI web E2E currently relies on anonymous create-account, which is blocked when + // content-keys binding is enabled but web crypto can't produce the binding signature reliably. + // Keep this test focused on the auth + terminal-connect + daemon flow first. + HAPPIER_BUILD_FEATURES_DENY: 'sharing.contentKeys', + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: '1', + // Make presence timeouts fast enough for UI E2E reconnect flows. + // NOTE: DB lastActiveAt updates are throttled, so the timeout needs to be comfortably above that threshold. + HAPPIER_PRESENCE_SESSION_TIMEOUT_MS: '60000', + HAPPIER_PRESENCE_MACHINE_TIMEOUT_MS: '60000', + HAPPIER_PRESENCE_TIMEOUT_TICK_MS: '1000', + }, + }); + ui = await startUiWeb({ + testDir: suiteDir, + env: { + ...process.env, + EXPO_PUBLIC_DEBUG: '1', + EXPO_PUBLIC_HAPPY_SERVER_URL: server.baseUrl, + EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: `e2e-${run.runId}`, + }, + }); + uiBaseUrl = normalizeLoopbackBaseUrl(ui.baseUrl); + } catch (error) { + throw error; + } + }); + + test.afterAll(async () => { + test.setTimeout(120_000); + await daemon?.stop().catch(() => {}); + await ui?.stop().catch(() => {}); + await server?.stop().catch(() => {}); + }); + + test('creates an account, approves terminal connect, then daemon becomes online', async ({ page }, testInfo) => { + test.setTimeout(420_000); + if (!server || !ui) throw new Error('missing server/ui fixtures'); + if (!uiBaseUrl) throw new Error('missing ui base url'); + + const pageConsole: string[] = []; + const pageErrors: string[] = []; + const requestFailures: string[] = []; + const responseErrors: string[] = []; + + page.on('console', (msg) => pageConsole.push(`[${msg.type()}] ${msg.text()}`)); + page.on('pageerror', (err) => pageErrors.push(String(err))); + page.on('requestfailed', (request) => { + const failure = request.failure(); + requestFailures.push(`${request.method()} ${request.url()} ${failure ? `-> ${failure.errorText}` : ''}`.trim()); + }); + page.on('response', (response) => { + const status = response.status(); + if (status >= 400) responseErrors.push(`${status} ${response.request().method()} ${response.url()}`); + }); + + const testDir = resolve(join(suiteDir, 't1-create-connect-daemon')); + await mkdir(testDir, { recursive: true }); + + let cliLogin: StartedCliTerminalConnect | null = null; + let thrown: unknown = null; + try { + await page.goto(uiBaseUrl, { waitUntil: 'domcontentloaded' }); + + await page.getByTestId('welcome-create-account').click(); + await expect(page.getByTestId('session-getting-started-kind-connect_machine')).not.toHaveCount(0, { timeout: 120_000 }); + + cliLogin = await startCliAuthLoginForTerminalConnect({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: { + ...process.env, + CI: '1', + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + }, + }); + + await page.goto(cliLogin.connectUrl, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('terminal-connect-approve')).toHaveCount(1, { timeout: 60_000 }); + await page.getByTestId('terminal-connect-approve').click(); + await cliLogin.waitForSuccess(); + + await page.goto(`${uiBaseUrl}/`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('session-getting-started-kind-start_daemon')).toHaveCount(0, { timeout: 120_000 }); + + fakeClaudeLogPath = resolve(join(testDir, 'fake-claude.jsonl')); + fakeClaudePath = fakeClaudeFixturePath(); + + daemon = await startTestDaemon({ + testDir, + happyHomeDir: cliHomeDir, + env: { + ...process.env, + CI: '1', + HAPPIER_HOME_DIR: cliHomeDir, + HAPPIER_SERVER_URL: server.baseUrl, + HAPPIER_WEBAPP_URL: uiBaseUrl, + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + HAPPIER_CLAUDE_PATH: fakeClaudePath, + HAPPIER_E2E_FAKE_CLAUDE_LOG: fakeClaudeLogPath, + HAPPIER_E2E_FAKE_CLAUDE_SESSION_ID: `fake-claude-session-${run.runId}`, + HAPPIER_E2E_FAKE_CLAUDE_INVOCATION_ID: `fake-claude-invocation-${run.runId}`, + }, + }); + + await expect + .poll( + async () => { + const createCount = await page.getByTestId('session-getting-started-kind-create_session').count(); + const selectCount = await page.getByTestId('session-getting-started-kind-select_session').count(); + return createCount > 0 || selectCount > 0; + }, + { timeout: 180_000 }, + ) + .toBe(true); + + accountSecretKeyFormatted = await readAccountSecretKeyFromSettings(page, uiBaseUrl); + } catch (error) { + thrown = error; + throw error; + } finally { + await cliLogin?.stop().catch(() => {}); + if (thrown) { + const diagnostic = + `# Browser diagnostics\n\n` + + `## Console\n\n${pageConsole.length ? pageConsole.join('\n') : '(none)'}\n\n` + + `## Page errors\n\n${pageErrors.length ? pageErrors.join('\n') : '(none)'}\n\n` + + `## Request failures\n\n${requestFailures.length ? requestFailures.join('\n') : '(none)'}\n\n` + + `## Response errors\n\n${responseErrors.length ? responseErrors.join('\n') : '(none)'}\n`; + await testInfo.attach('browser-diagnostics.md', { body: diagnostic, contentType: 'text/markdown' }); + } + } + }); + + test('restores the same account using secret key', async ({ page }, testInfo) => { + test.setTimeout(300_000); + if (!ui) throw new Error('missing ui fixture'); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!accountSecretKeyFormatted) throw new Error('missing account secret key from prior test'); + + const pageConsole: string[] = []; + const pageErrors: string[] = []; + const requestFailures: string[] = []; + const responseErrors: string[] = []; + + page.on('console', (msg) => pageConsole.push(`[${msg.type()}] ${msg.text()}`)); + page.on('pageerror', (err) => pageErrors.push(String(err))); + page.on('requestfailed', (request) => { + const failure = request.failure(); + requestFailures.push(`${request.method()} ${request.url()} ${failure ? `-> ${failure.errorText}` : ''}`.trim()); + }); + page.on('response', (response) => { + const status = response.status(); + if (status >= 400) responseErrors.push(`${status} ${response.request().method()} ${response.url()}`); + }); + + let thrown: unknown = null; + try { + await restoreAccountUsingSecretKey(page, uiBaseUrl, accountSecretKeyFormatted, { postRestorePath: '/new' }); + + await expect(page.getByTestId('new-session-composer-input')).toHaveCount(1, { timeout: 60_000 }); + const prompt = `UI_E2E_MESSAGE_${run.runId}`; + await page.getByTestId('new-session-composer-input').fill(prompt); + await page.getByTestId('new-session-composer-input').press('Enter'); + + await expect(page.getByTestId('session-composer-input')).toHaveCount(1, { timeout: 180_000 }); + await expect(page.getByText('FAKE_CLAUDE_OK_1')).toHaveCount(1, { timeout: 180_000 }); + + const currentUrl = page.url(); + const { pathname } = new URL(currentUrl); + const parts = pathname.split('/').filter(Boolean); + const sessionIndex = parts.indexOf('session'); + createdSessionId = sessionIndex >= 0 ? (parts[sessionIndex + 1] ?? null) : null; + if (!createdSessionId) { + throw new Error(`Failed to infer session id from url: ${currentUrl}`); + } + } catch (error) { + thrown = error; + throw error; + } finally { + if (thrown) { + const diagnostic = + `# Browser diagnostics\n\n` + + `## Console\n\n${pageConsole.length ? pageConsole.join('\n') : '(none)'}\n\n` + + `## Page errors\n\n${pageErrors.length ? pageErrors.join('\n') : '(none)'}\n\n` + + `## Request failures\n\n${requestFailures.length ? requestFailures.join('\n') : '(none)'}\n\n` + + `## Response errors\n\n${responseErrors.length ? responseErrors.join('\n') : '(none)'}\n`; + await testInfo.attach('browser-diagnostics.md', { body: diagnostic, contentType: 'text/markdown' }); + + if (fakeClaudeLogPath) { + await testInfo + .attach('fake-claude.jsonl', { path: fakeClaudeLogPath, contentType: 'text/plain' }) + .catch(() => {}); + } + } + } + }); + + test('daemon can reconnect and UI reflects offline → online', async ({ page }, testInfo) => { + test.setTimeout(420_000); + if (!ui) throw new Error('missing ui fixture'); + if (!server) throw new Error('missing server fixture'); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!accountSecretKeyFormatted) throw new Error('missing account secret key from prior test'); + if (!createdSessionId) throw new Error('missing session id from prior test'); + if (!daemon) throw new Error('missing daemon from prior test'); + if (!fakeClaudePath) throw new Error('missing fake Claude path from prior test'); + + const pageConsole: string[] = []; + const pageErrors: string[] = []; + const requestFailures: string[] = []; + const responseErrors: string[] = []; + + page.on('console', (msg) => pageConsole.push(`[${msg.type()}] ${msg.text()}`)); + page.on('pageerror', (err) => pageErrors.push(String(err))); + page.on('requestfailed', (request) => { + const failure = request.failure(); + requestFailures.push(`${request.method()} ${request.url()} ${failure ? `-> ${failure.errorText}` : ''}`.trim()); + }); + page.on('response', (response) => { + const status = response.status(); + if (status >= 400) responseErrors.push(`${status} ${response.request().method()} ${response.url()}`); + }); + + const testDir = resolve(join(suiteDir, 't3-daemon-reconnect')); + await mkdir(testDir, { recursive: true }); + + let thrown: unknown = null; + try { + await restoreAccountUsingSecretKey(page, uiBaseUrl, accountSecretKeyFormatted); + await page.goto(`${uiBaseUrl}/session/${createdSessionId}`, { waitUntil: 'domcontentloaded' }); + + const ok1 = page.getByText('FAKE_CLAUDE_OK_1'); + const ok1Before = await ok1.count(); + + const machineId = readLatestMachineIdFromServerLightDb({ suiteDir }); + await daemon.stop(); + daemon = null; + + await expect + .poll(async () => { + return readMachineActiveFromServerLightDb({ suiteDir, machineId }); + }, { timeout: 180_000 }) + .toBe(false); + + fakeClaudeLogPath = resolve(join(testDir, 'fake-claude.jsonl')); + daemon = await startTestDaemon({ + testDir, + happyHomeDir: cliHomeDir, + env: { + ...process.env, + CI: '1', + HAPPIER_HOME_DIR: cliHomeDir, + HAPPIER_SERVER_URL: server.baseUrl, + HAPPIER_WEBAPP_URL: uiBaseUrl, + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + HAPPIER_CLAUDE_PATH: fakeClaudePath, + HAPPIER_E2E_FAKE_CLAUDE_LOG: fakeClaudeLogPath, + HAPPIER_E2E_FAKE_CLAUDE_SESSION_ID: `fake-claude-session-${run.runId}`, + HAPPIER_E2E_FAKE_CLAUDE_INVOCATION_ID: `fake-claude-invocation-${run.runId}`, + }, + }); + + await expect + .poll(async () => { + return readMachineActiveFromServerLightDb({ suiteDir, machineId }); + }, { timeout: 180_000 }) + .toBe(true); + + await page.goto(`${uiBaseUrl}/session/${createdSessionId}`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('session-composer-input')).toHaveCount(1, { timeout: 120_000 }); + + const followup = `UI_E2E_MESSAGE_RECONNECT_${run.runId}`; + await page.getByTestId('session-composer-input').fill(followup); + await page.getByTestId('session-composer-input').press('Enter'); + await expect(ok1).toHaveCount(ok1Before + 1, { timeout: 180_000 }); + } catch (error) { + thrown = error; + throw error; + } finally { + if (thrown) { + const diagnostic = + `# Browser diagnostics\n\n` + + `## Console\n\n${pageConsole.length ? pageConsole.join('\n') : '(none)'}\n\n` + + `## Page errors\n\n${pageErrors.length ? pageErrors.join('\n') : '(none)'}\n\n` + + `## Request failures\n\n${requestFailures.length ? requestFailures.join('\n') : '(none)'}\n\n` + + `## Response errors\n\n${responseErrors.length ? responseErrors.join('\n') : '(none)'}\n`; + await testInfo.attach('browser-diagnostics.md', { body: diagnostic, contentType: 'text/markdown' }); + } + } + }); + + test('queues messages while daemon is offline and delivers them after reconnect', async ({ page }, testInfo) => { + test.setTimeout(420_000); + if (!ui) throw new Error('missing ui fixture'); + if (!server) throw new Error('missing server fixture'); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!accountSecretKeyFormatted) throw new Error('missing account secret key from prior test'); + if (!createdSessionId) throw new Error('missing session id from prior test'); + if (!daemon) throw new Error('missing daemon from prior test'); + if (!fakeClaudePath) throw new Error('missing fake Claude path from prior test'); + + const pageConsole: string[] = []; + const pageErrors: string[] = []; + const requestFailures: string[] = []; + const responseErrors: string[] = []; + + page.on('console', (msg) => pageConsole.push(`[${msg.type()}] ${msg.text()}`)); + page.on('pageerror', (err) => pageErrors.push(String(err))); + page.on('requestfailed', (request) => { + const failure = request.failure(); + requestFailures.push(`${request.method()} ${request.url()} ${failure ? `-> ${failure.errorText}` : ''}`.trim()); + }); + page.on('response', (response) => { + const status = response.status(); + if (status >= 400) responseErrors.push(`${status} ${response.request().method()} ${response.url()}`); + }); + + const testDir = resolve(join(suiteDir, 't4-offline-queue')); + await mkdir(testDir, { recursive: true }); + + let thrown: unknown = null; + try { + await restoreAccountUsingSecretKey(page, uiBaseUrl, accountSecretKeyFormatted); + await page.goto(`${uiBaseUrl}/session/${createdSessionId}`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('session-composer-input')).toHaveCount(1, { timeout: 120_000 }); + + // Ensure prior transcript content is loaded before we snapshot `okBefore`, otherwise + // late-arriving history can be mistaken for an offline daemon response. + await expect(page.getByText('FAKE_CLAUDE_OK_1')).toHaveCount(1, { timeout: 120_000 }); + + const ok = page.getByText(/FAKE_CLAUDE_OK_/); + const okBefore = await ok.count(); + + const machineId = readLatestMachineIdFromServerLightDb({ suiteDir }); + await daemon.stop(); + daemon = null; + + await expect + .poll(async () => { + return readMachineActiveFromServerLightDb({ suiteDir, machineId }); + }, { timeout: 180_000 }) + .toBe(false); + + const prompt = `UI_E2E_MESSAGE_OFFLINE_${run.runId}`; + await page.getByTestId('session-composer-input').fill(prompt); + await page.getByTestId('session-composer-input').press('Enter'); + await expect(page.getByText(prompt)).toHaveCount(1, { timeout: 60_000 }); + + // While the daemon is offline, we should not receive a provider response. + await expect + .poll(async () => { + return await ok.count(); + }, { timeout: 15_000 }) + .toBe(okBefore); + + fakeClaudeLogPath = resolve(join(testDir, 'fake-claude.jsonl')); + daemon = await startTestDaemon({ + testDir, + happyHomeDir: cliHomeDir, + env: { + ...process.env, + CI: '1', + HAPPIER_HOME_DIR: cliHomeDir, + HAPPIER_SERVER_URL: server.baseUrl, + HAPPIER_WEBAPP_URL: uiBaseUrl, + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + HAPPIER_CLAUDE_PATH: fakeClaudePath, + HAPPIER_E2E_FAKE_CLAUDE_LOG: fakeClaudeLogPath, + HAPPIER_E2E_FAKE_CLAUDE_SESSION_ID: `fake-claude-session-${run.runId}`, + HAPPIER_E2E_FAKE_CLAUDE_INVOCATION_ID: `fake-claude-invocation-${run.runId}`, + }, + }); + + await expect + .poll(async () => { + return readMachineActiveFromServerLightDb({ suiteDir, machineId }); + }, { timeout: 180_000 }) + .toBe(true); + + await expect(ok).toHaveCount(okBefore + 1, { timeout: 180_000 }); + } catch (error) { + thrown = error; + throw error; + } finally { + if (thrown) { + const diagnostic = + `# Browser diagnostics\n\n` + + `## Console\n\n${pageConsole.length ? pageConsole.join('\n') : '(none)'}\n\n` + + `## Page errors\n\n${pageErrors.length ? pageErrors.join('\n') : '(none)'}\n\n` + + `## Request failures\n\n${requestFailures.length ? requestFailures.join('\n') : '(none)'}\n\n` + + `## Response errors\n\n${responseErrors.length ? responseErrors.join('\n') : '(none)'}\n`; + await testInfo.attach('browser-diagnostics.md', { body: diagnostic, contentType: 'text/markdown' }); + } + } + }); + + test('selects the existing session from the list', async ({ page }, testInfo) => { + test.setTimeout(420_000); + if (!ui) throw new Error('missing ui fixture'); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!accountSecretKeyFormatted) throw new Error('missing account secret key from prior test'); + if (!createdSessionId) throw new Error('missing session id from prior test'); + + const pageConsole: string[] = []; + const pageErrors: string[] = []; + const requestFailures: string[] = []; + const responseErrors: string[] = []; + + page.on('console', (msg) => pageConsole.push(`[${msg.type()}] ${msg.text()}`)); + page.on('pageerror', (err) => pageErrors.push(String(err))); + page.on('requestfailed', (request) => { + const failure = request.failure(); + requestFailures.push(`${request.method()} ${request.url()} ${failure ? `-> ${failure.errorText}` : ''}`.trim()); + }); + page.on('response', (response) => { + const status = response.status(); + if (status >= 400) responseErrors.push(`${status} ${response.request().method()} ${response.url()}`); + }); + + let thrown: unknown = null; + try { + await restoreAccountUsingSecretKey(page, uiBaseUrl, accountSecretKeyFormatted); + await page.goto(`${uiBaseUrl}/`, { waitUntil: 'domcontentloaded' }); + + const sessionItem = page.getByTestId(`session-list-item-${createdSessionId}`); + await expect(sessionItem).toHaveCount(1, { timeout: 120_000 }); + await sessionItem.click(); + + await expect(page.getByTestId('session-composer-input')).toHaveCount(1, { timeout: 120_000 }); + await expect(page).toHaveURL(`${uiBaseUrl}/session/${createdSessionId}`, { timeout: 60_000 }); + } catch (error) { + thrown = error; + throw error; + } finally { + if (thrown) { + const diagnostic = + `# Browser diagnostics\n\n` + + `## Console\n\n${pageConsole.length ? pageConsole.join('\n') : '(none)'}\n\n` + + `## Page errors\n\n${pageErrors.length ? pageErrors.join('\n') : '(none)'}\n\n` + + `## Request failures\n\n${requestFailures.length ? requestFailures.join('\n') : '(none)'}\n\n` + + `## Response errors\n\n${responseErrors.length ? responseErrors.join('\n') : '(none)'}\n`; + await testInfo.attach('browser-diagnostics.md', { body: diagnostic, contentType: 'text/markdown' }); + + if (fakeClaudeLogPath) { + await testInfo + .attach('fake-claude.jsonl', { path: fakeClaudeLogPath, contentType: 'text/plain' }) + .catch(() => {}); + } + } + } + }); + + test('terminal-connect link redirects to welcome when logged out, then can be approved after restore', async ({ page }, testInfo) => { + test.setTimeout(420_000); + if (!server || !ui) throw new Error('missing server/ui fixtures'); + if (!uiBaseUrl) throw new Error('missing ui base url'); + if (!accountSecretKeyFormatted) throw new Error('missing account secret key from prior test'); + + const pageConsole: string[] = []; + const pageErrors: string[] = []; + const requestFailures: string[] = []; + const responseErrors: string[] = []; + + page.on('console', (msg) => pageConsole.push(`[${msg.type()}] ${msg.text()}`)); + page.on('pageerror', (err) => pageErrors.push(String(err))); + page.on('requestfailed', (request) => { + const failure = request.failure(); + requestFailures.push(`${request.method()} ${request.url()} ${failure ? `-> ${failure.errorText}` : ''}`.trim()); + }); + page.on('response', (response) => { + const status = response.status(); + if (status >= 400) responseErrors.push(`${status} ${response.request().method()} ${response.url()}`); + }); + + const testDir = resolve(join(suiteDir, 't5-terminal-connect-unauth')); + await mkdir(testDir, { recursive: true }); + + let cliLogin: StartedCliTerminalConnect | null = null; + let thrown: unknown = null; + try { + cliLogin = await startCliAuthLoginForTerminalConnect({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: { + ...process.env, + CI: '1', + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + }, + }); + + await page.goto(cliLogin.connectUrl, { waitUntil: 'domcontentloaded' }); + await expect(page.locator('[data-testid="welcome-terminal-connect-intent"]:visible')).toHaveCount(1, { timeout: 60_000 }); + await expect(page.locator('[data-testid="welcome-restore"]:visible')).toHaveCount(1, { timeout: 60_000 }); + + // Restore account. The app should automatically open the pending terminal connect approval screen. + await restoreAccountUsingSecretKey(page, uiBaseUrl, accountSecretKeyFormatted, { postRestorePath: null }); + + await page.waitForURL((url) => url.pathname.startsWith('/terminal'), { timeout: 120_000 }); + const approve = page.getByTestId('terminal-connect-approve'); + await expect(approve).toHaveCount(1, { timeout: 120_000 }); + + await page.getByTestId('terminal-connect-approve').click(); + await cliLogin.waitForSuccess(); + } catch (error) { + thrown = error; + throw error; + } finally { + await cliLogin?.stop().catch(() => {}); + if (thrown) { + const diagnostic = + `# Browser diagnostics\n\n` + + `## Console\n\n${pageConsole.length ? pageConsole.join('\n') : '(none)'}\n\n` + + `## Page errors\n\n${pageErrors.length ? pageErrors.join('\n') : '(none)'}\n\n` + + `## Request failures\n\n${requestFailures.length ? requestFailures.join('\n') : '(none)'}\n\n` + + `## Response errors\n\n${responseErrors.length ? responseErrors.join('\n') : '(none)'}\n`; + await testInfo.attach('browser-diagnostics.md', { body: diagnostic, contentType: 'text/markdown' }); + } + } + }); +}); diff --git a/packages/tests/suites/ui-e2e/encryptionOptOut.modeSwitch.readBoth.spec.ts b/packages/tests/suites/ui-e2e/encryptionOptOut.modeSwitch.readBoth.spec.ts new file mode 100644 index 000000000..6d4487d1d --- /dev/null +++ b/packages/tests/suites/ui-e2e/encryptionOptOut.modeSwitch.readBoth.spec.ts @@ -0,0 +1,261 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; +import { startUiWeb, type StartedUiWeb } from '../../src/testkit/process/uiWeb'; +import { startCliAuthLoginForTerminalConnect, type StartedCliTerminalConnect } from '../../src/testkit/uiE2e/cliTerminalConnect'; +import { gotoDomContentLoadedWithRetries, normalizeLoopbackBaseUrl } from '../../src/testkit/uiE2e/pageNavigation'; +import { runCliJson } from '../../src/testkit/uiE2e/cliJson'; + +const run = createRunDirs({ runLabel: 'ui-e2e' }); + +function collectBrowserDiagnostics(params: Readonly<{ page: Page }>): () => string { + const pageConsole: string[] = []; + const pageErrors: string[] = []; + const requestFailures: string[] = []; + const responseErrors: string[] = []; + + params.page.on('console', (msg) => pageConsole.push(`[${msg.type()}] ${msg.text()}`)); + params.page.on('pageerror', (err) => pageErrors.push(String(err))); + params.page.on('requestfailed', (request) => { + const failure = request.failure(); + requestFailures.push(`${request.method()} ${request.url()} ${failure ? `-> ${failure.errorText}` : ''}`.trim()); + }); + params.page.on('response', (response) => { + const status = response.status(); + if (status >= 400) responseErrors.push(`${status} ${response.request().method()} ${response.url()}`); + }); + + return () => + `# Browser diagnostics\n\n` + + `## Console\n\n${pageConsole.length ? pageConsole.join('\n') : '(none)'}\n\n` + + `## Page errors\n\n${pageErrors.length ? pageErrors.join('\n') : '(none)'}\n\n` + + `## Request failures\n\n${requestFailures.length ? requestFailures.join('\n') : '(none)'}\n\n` + + `## Response errors\n\n${responseErrors.length ? responseErrors.join('\n') : '(none)'}\n`; +} + +async function toggleAccountEncryptionMode(params: Readonly<{ page: Page; uiBaseUrl: string; expectedMode: 'plain' | 'e2ee' }>): Promise<void> { + await params.page.goto(`${params.uiBaseUrl}/settings/account`, { waitUntil: 'domcontentloaded' }); + await expect(params.page.getByTestId('settings-account-encryption-mode-switch')).toHaveCount(1, { timeout: 120_000 }); + const patchOk = params.page.waitForResponse( + (resp) => resp.url().endsWith('/v1/account/encryption') && resp.request().method() === 'PATCH' && resp.status() === 200, + { timeout: 60_000 }, + ); + await params.page.getByTestId('settings-account-encryption-mode-switch').click(); + const patchResp = await patchOk; + const patchJson = (await patchResp.json()) as { mode?: unknown }; + expect(patchJson?.mode).toBe(params.expectedMode); +} + +test.describe('ui e2e: encryption opt-out mode switching', () => { + test.describe.configure({ mode: 'serial' }); + + const suiteDir = run.testDir('encryption-optout-mode-switch-suite'); + const cliHomeDir = resolve(join(suiteDir, 'cli-home')); + + let server: StartedServer | null = null; + let ui: StartedUiWeb | null = null; + let uiBaseUrl: string | null = null; + + test.beforeAll(async () => { + test.setTimeout(420_000); + await mkdir(cliHomeDir, { recursive: true }); + + server = await startServerLight({ + testDir: suiteDir, + dbProvider: 'sqlite', + extraEnv: { + // Keep web create-account stable (binding signature is not reliably available on web). + HAPPIER_BUILD_FEATURES_DENY: 'sharing.contentKeys', + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: '1', + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT: '1', + HAPPIER_PRESENCE_SESSION_TIMEOUT_MS: '60000', + HAPPIER_PRESENCE_MACHINE_TIMEOUT_MS: '60000', + HAPPIER_PRESENCE_TIMEOUT_TICK_MS: '1000', + }, + }); + + ui = await startUiWeb({ + testDir: suiteDir, + env: { + ...process.env, + EXPO_PUBLIC_DEBUG: '1', + EXPO_PUBLIC_HAPPY_SERVER_URL: server.baseUrl, + EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: `e2e-${run.runId}`, + }, + }); + + uiBaseUrl = normalizeLoopbackBaseUrl(ui.baseUrl); + }); + + test.afterAll(async () => { + test.setTimeout(120_000); + await ui?.stop().catch(() => {}); + await server?.stop().catch(() => {}); + }); + + test('switches modes and keeps old sessions readable (e2ee → plain → e2ee)', async ({ page }, testInfo) => { + test.setTimeout(420_000); + if (!server || !ui) throw new Error('missing server/ui fixtures'); + if (!uiBaseUrl) throw new Error('missing ui base url'); + + const testDir = resolve(join(suiteDir, 't1-mode-switch')); + await mkdir(testDir, { recursive: true }); + + const diagnostics = collectBrowserDiagnostics({ page }); + + let cliLogin: StartedCliTerminalConnect | null = null; + let thrown: unknown = null; + try { + await gotoDomContentLoadedWithRetries(page, uiBaseUrl); + await page.getByTestId('welcome-create-account').click(); + await expect(page.getByTestId('session-getting-started-kind-connect_machine')).not.toHaveCount(0, { timeout: 120_000 }); + + cliLogin = await startCliAuthLoginForTerminalConnect({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: { + ...process.env, + CI: '1', + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + }, + }); + + await gotoDomContentLoadedWithRetries(page, cliLogin.connectUrl, 90_000); + await expect(page.getByTestId('terminal-connect-approve')).toHaveCount(1, { timeout: 60_000 }); + await page.getByTestId('terminal-connect-approve').click(); + await cliLogin.waitForSuccess(); + + const tagA = `ui-e2e-e2ee-a-${run.runId}`; + const msgA = `hello e2ee A ${run.runId}`; + + const createA = await runCliJson({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: process.env, + label: 'session-create-a', + args: ['session', 'create', '--tag', tagA, '--no-load-existing', '--json'], + timeoutMs: 120_000, + }); + expect(createA.ok).toBe(true); + expect(createA.kind).toBe('session_create'); + expect((createA as any)?.data?.session?.encryptionMode).toBe('e2ee'); + const sessionAId = String((createA as any)?.data?.session?.id ?? ''); + expect(sessionAId).toMatch(/\S+/); + + const sendA = await runCliJson({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: process.env, + label: 'session-send-a', + args: ['session', 'send', sessionAId, msgA, '--json'], + timeoutMs: 120_000, + }); + expect(sendA.ok).toBe(true); + expect(sendA.kind).toBe('session_send'); + + await gotoDomContentLoadedWithRetries(page, `${uiBaseUrl}/session/${sessionAId}`, 120_000); + await expect(page.getByText(msgA)).toHaveCount(1, { timeout: 120_000 }); + + await toggleAccountEncryptionMode({ page, uiBaseUrl, expectedMode: 'plain' }); + + const tagB = `ui-e2e-plain-b-${run.runId}`; + const msgB = `hello plain B ${run.runId}`; + + const createB = await runCliJson({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: process.env, + label: 'session-create-b', + args: ['session', 'create', '--tag', tagB, '--no-load-existing', '--json'], + timeoutMs: 120_000, + }); + expect(createB.ok).toBe(true); + expect(createB.kind).toBe('session_create'); + expect((createB as any)?.data?.session?.encryptionMode).toBe('plain'); + const sessionBId = String((createB as any)?.data?.session?.id ?? ''); + expect(sessionBId).toMatch(/\S+/); + + const sendB = await runCliJson({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: process.env, + label: 'session-send-b', + args: ['session', 'send', sessionBId, msgB, '--json'], + timeoutMs: 120_000, + }); + expect(sendB.ok).toBe(true); + expect(sendB.kind).toBe('session_send'); + + await gotoDomContentLoadedWithRetries(page, `${uiBaseUrl}/session/${sessionBId}`, 120_000); + await expect(page.getByText(msgB)).toHaveCount(1, { timeout: 120_000 }); + + await toggleAccountEncryptionMode({ page, uiBaseUrl, expectedMode: 'e2ee' }); + + const tagC = `ui-e2e-e2ee-c-${run.runId}`; + const msgC = `hello e2ee C ${run.runId}`; + + const createC = await runCliJson({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: process.env, + label: 'session-create-c', + args: ['session', 'create', '--tag', tagC, '--no-load-existing', '--json'], + timeoutMs: 120_000, + }); + expect(createC.ok).toBe(true); + expect(createC.kind).toBe('session_create'); + expect((createC as any)?.data?.session?.encryptionMode).toBe('e2ee'); + const sessionCId = String((createC as any)?.data?.session?.id ?? ''); + expect(sessionCId).toMatch(/\S+/); + + const sendC = await runCliJson({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: process.env, + label: 'session-send-c', + args: ['session', 'send', sessionCId, msgC, '--json'], + timeoutMs: 120_000, + }); + expect(sendC.ok).toBe(true); + expect(sendC.kind).toBe('session_send'); + + await gotoDomContentLoadedWithRetries(page, `${uiBaseUrl}/session/${sessionCId}`, 120_000); + await expect(page.getByText(msgC)).toHaveCount(1, { timeout: 120_000 }); + + // Ensure older sessions remain readable after toggling account mode. + await gotoDomContentLoadedWithRetries(page, `${uiBaseUrl}/session/${sessionAId}`, 120_000); + await expect(page.getByText(msgA)).toHaveCount(1, { timeout: 120_000 }); + + await gotoDomContentLoadedWithRetries(page, `${uiBaseUrl}/session/${sessionBId}`, 120_000); + await expect(page.getByText(msgB)).toHaveCount(1, { timeout: 120_000 }); + } catch (error) { + thrown = error; + throw error; + } finally { + await cliLogin?.stop().catch(() => {}); + if (thrown) { + await testInfo.attach('browser-diagnostics.md', { body: diagnostics(), contentType: 'text/markdown' }); + } + } + }); +}); + diff --git a/packages/tests/suites/ui-e2e/encryptionOptOut.publicShare.plaintext.spec.ts b/packages/tests/suites/ui-e2e/encryptionOptOut.publicShare.plaintext.spec.ts new file mode 100644 index 000000000..2daac9069 --- /dev/null +++ b/packages/tests/suites/ui-e2e/encryptionOptOut.publicShare.plaintext.spec.ts @@ -0,0 +1,213 @@ +import { test, expect, type Page, type BrowserContext } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; +import { startUiWeb, type StartedUiWeb } from '../../src/testkit/process/uiWeb'; +import { startCliAuthLoginForTerminalConnect, type StartedCliTerminalConnect } from '../../src/testkit/uiE2e/cliTerminalConnect'; +import { gotoDomContentLoadedWithRetries, normalizeLoopbackBaseUrl } from '../../src/testkit/uiE2e/pageNavigation'; +import { runCliJson } from '../../src/testkit/uiE2e/cliJson'; + +const run = createRunDirs({ runLabel: 'ui-e2e' }); + +function collectBrowserDiagnostics(params: Readonly<{ page: Page }>): () => string { + const pageConsole: string[] = []; + const pageErrors: string[] = []; + const requestFailures: string[] = []; + const responseErrors: string[] = []; + + params.page.on('console', (msg) => pageConsole.push(`[${msg.type()}] ${msg.text()}`)); + params.page.on('pageerror', (err) => pageErrors.push(String(err))); + params.page.on('requestfailed', (request) => { + const failure = request.failure(); + requestFailures.push(`${request.method()} ${request.url()} ${failure ? `-> ${failure.errorText}` : ''}`.trim()); + }); + params.page.on('response', (response) => { + const status = response.status(); + if (status >= 400) responseErrors.push(`${status} ${response.request().method()} ${response.url()}`); + }); + + return () => + `# Browser diagnostics\n\n` + + `## Console\n\n${pageConsole.length ? pageConsole.join('\n') : '(none)'}\n\n` + + `## Page errors\n\n${pageErrors.length ? pageErrors.join('\n') : '(none)'}\n\n` + + `## Request failures\n\n${requestFailures.length ? requestFailures.join('\n') : '(none)'}\n\n` + + `## Response errors\n\n${responseErrors.length ? responseErrors.join('\n') : '(none)'}\n`; +} + +async function extractPublicShareUrlFromDialog(params: Readonly<{ dialog: ReturnType<Page['getByRole']> }>): Promise<string> { + const locator = params.dialog.locator('text=/https?:\\/\\/[^\\s]+\\/share\\/[0-9a-f]+/i').first(); + const raw = (await locator.innerText()).trim(); + const match = raw.match(/https?:\/\/[^\s]+\/share\/[0-9a-f]+/i); + if (!match) throw new Error(`Failed to extract share URL from dialog text: ${JSON.stringify(raw)}`); + return match[0]; +} + +async function openShareInFreshContext(params: Readonly<{ baseContext: BrowserContext; url: string }>): Promise<Page> { + const browser = params.baseContext.browser(); + if (!browser) throw new Error('Missing browser instance'); + const context = await browser.newContext(); + const page = await context.newPage(); + await gotoDomContentLoadedWithRetries(page, params.url, 90_000); + return page; +} + +test.describe('ui e2e: plaintext mode + public share', () => { + test.describe.configure({ mode: 'serial' }); + + const suiteDir = run.testDir('encryption-optout-public-share-suite'); + const cliHomeDir = resolve(join(suiteDir, 'cli-home')); + + let server: StartedServer | null = null; + let ui: StartedUiWeb | null = null; + let uiBaseUrl: string | null = null; + + test.beforeAll(async () => { + test.setTimeout(420_000); + await mkdir(cliHomeDir, { recursive: true }); + + server = await startServerLight({ + testDir: suiteDir, + dbProvider: 'sqlite', + extraEnv: { + // Keep web create-account stable (binding signature is not reliably available on web). + HAPPIER_BUILD_FEATURES_DENY: 'sharing.contentKeys', + HAPPIER_FEATURE_AUTH_LOGIN__KEY_CHALLENGE_ENABLED: '1', + HAPPIER_FEATURE_ENCRYPTION__STORAGE_POLICY: 'optional', + HAPPIER_FEATURE_ENCRYPTION__ALLOW_ACCOUNT_OPTOUT: '1', + // Make presence timeouts fast enough for UI E2E reconnect flows. + HAPPIER_PRESENCE_SESSION_TIMEOUT_MS: '60000', + HAPPIER_PRESENCE_MACHINE_TIMEOUT_MS: '60000', + HAPPIER_PRESENCE_TIMEOUT_TICK_MS: '1000', + }, + }); + + ui = await startUiWeb({ + testDir: suiteDir, + env: { + ...process.env, + EXPO_PUBLIC_DEBUG: '1', + EXPO_PUBLIC_HAPPY_SERVER_URL: server.baseUrl, + EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: `e2e-${run.runId}`, + }, + }); + + uiBaseUrl = normalizeLoopbackBaseUrl(ui.baseUrl); + }); + + test.afterAll(async () => { + test.setTimeout(120_000); + await ui?.stop().catch(() => {}); + await server?.stop().catch(() => {}); + }); + + test('toggles account to plaintext, writes a plaintext session, and opens a public link', async ({ page, context }, testInfo) => { + test.setTimeout(420_000); + if (!server || !ui) throw new Error('missing server/ui fixtures'); + if (!uiBaseUrl) throw new Error('missing ui base url'); + + const testDir = resolve(join(suiteDir, 't1-plaintext-public-share')); + await mkdir(testDir, { recursive: true }); + + const diagnostics = collectBrowserDiagnostics({ page }); + + let cliLogin: StartedCliTerminalConnect | null = null; + let thrown: unknown = null; + try { + await gotoDomContentLoadedWithRetries(page, uiBaseUrl); + await page.getByTestId('welcome-create-account').click(); + await expect(page.getByTestId('session-getting-started-kind-connect_machine')).not.toHaveCount(0, { timeout: 120_000 }); + + cliLogin = await startCliAuthLoginForTerminalConnect({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: { + ...process.env, + CI: '1', + HAPPIER_DISABLE_CAFFEINATE: '1', + HAPPIER_VARIANT: 'dev', + }, + }); + + await gotoDomContentLoadedWithRetries(page, cliLogin.connectUrl, 90_000); + await expect(page.getByTestId('terminal-connect-approve')).toHaveCount(1, { timeout: 60_000 }); + await page.getByTestId('terminal-connect-approve').click(); + await cliLogin.waitForSuccess(); + + await page.goto(`${uiBaseUrl}/settings/account`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('settings-account-encryption-mode-switch')).toHaveCount(1, { timeout: 120_000 }); + + const patchOk = page.waitForResponse((resp) => resp.url().endsWith('/v1/account/encryption') && resp.request().method() === 'PATCH' && resp.status() === 200, { timeout: 60_000 }); + await page.getByTestId('settings-account-encryption-mode-switch').click(); + const patchResp = await patchOk; + const patchJson = (await patchResp.json()) as { mode?: unknown }; + expect(patchJson?.mode).toBe('plain'); + + const tag = `ui-e2e-plain-${run.runId}`; + const message = `hello from plain ${run.runId}`; + + const createEnvelope = await runCliJson({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: process.env, + label: 'session-create', + args: ['session', 'create', '--tag', tag, '--no-load-existing', '--json'], + timeoutMs: 120_000, + }); + expect(createEnvelope.ok).toBe(true); + expect(createEnvelope.kind).toBe('session_create'); + const createdSessionId = (createEnvelope as any)?.data?.session?.id; + expect(typeof createdSessionId).toBe('string'); + expect((createEnvelope as any)?.data?.session?.encryptionMode).toBe('plain'); + + const sessionId = String(createdSessionId); + + const sendEnvelope = await runCliJson({ + testDir, + cliHomeDir, + serverUrl: server.baseUrl, + webappUrl: uiBaseUrl, + env: process.env, + label: 'session-send', + args: ['session', 'send', sessionId, message, '--json'], + timeoutMs: 120_000, + }); + expect(sendEnvelope.ok).toBe(true); + expect(sendEnvelope.kind).toBe('session_send'); + + await gotoDomContentLoadedWithRetries(page, `${uiBaseUrl}/session/${sessionId}`, 120_000); + await expect(page.getByText(message)).toHaveCount(1, { timeout: 120_000 }); + + await gotoDomContentLoadedWithRetries(page, `${uiBaseUrl}/session/${sessionId}/sharing`, 120_000); + await expect(page.getByText('Create public link')).toHaveCount(1, { timeout: 120_000 }); + await page.getByText('Create public link').click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', { name: 'Create public link' })).toHaveCount(1, { timeout: 60_000 }); + await dialog.getByRole('button', { name: 'Create public link' }).click(); + + const shareUrl = await extractPublicShareUrlFromDialog({ dialog }); + expect(shareUrl).toContain('/share/'); + + const sharePage = await openShareInFreshContext({ baseContext: context, url: shareUrl }); + await expect(sharePage.getByText('Consent required')).toHaveCount(1, { timeout: 120_000 }); + await sharePage.getByText('Accept and view').click(); + await expect(sharePage.getByText('Public link (read-only)')).toHaveCount(1, { timeout: 120_000 }); + await expect(sharePage.getByText(message)).toHaveCount(1, { timeout: 120_000 }); + await sharePage.context().close().catch(() => {}); + } catch (error) { + thrown = error; + throw error; + } finally { + await cliLogin?.stop().catch(() => {}); + if (thrown) { + await testInfo.attach('browser-diagnostics.md', { body: diagnostics(), contentType: 'text/markdown' }); + } + } + }); +}); diff --git a/packages/tests/vitest.core.config.ts b/packages/tests/vitest.core.config.ts index af5930d9f..c3ba3298b 100644 --- a/packages/tests/vitest.core.config.ts +++ b/packages/tests/vitest.core.config.ts @@ -7,8 +7,11 @@ export default defineConfig({ environment: 'node', include: [ 'suites/core-e2e/**/*.test.ts', + 'src/testkit/cliAccessKey.spec.ts', 'src/testkit/process/serverLight.plan.spec.ts', 'src/testkit/process/extendedDbDocker.plan.spec.ts', + 'src/testkit/process/uiWebHtml.spec.ts', + 'src/testkit/process/uiWeb.baseUrl.spec.ts', 'src/testkit/env.spec.ts', 'src/testkit/daemon/daemon.statePath.spec.ts', 'src/testkit/providers/satisfaction/messageSatisfaction.spec.ts', diff --git a/packages/tests/vitest.core.fast.config.ts b/packages/tests/vitest.core.fast.config.ts index 434a45be2..34c1d937c 100644 --- a/packages/tests/vitest.core.fast.config.ts +++ b/packages/tests/vitest.core.fast.config.ts @@ -7,8 +7,10 @@ export default defineConfig({ environment: 'node', include: [ 'suites/core-e2e/**/*.test.ts', + 'src/testkit/cliAccessKey.spec.ts', 'src/testkit/process/serverLight.plan.spec.ts', 'src/testkit/process/extendedDbDocker.plan.spec.ts', + 'src/testkit/process/uiWebHtml.spec.ts', 'src/testkit/env.spec.ts', 'src/testkit/daemon/daemon.statePath.spec.ts', 'src/testkit/providers/satisfaction/messageSatisfaction.spec.ts', diff --git a/scripts/ci/run-act-tests.sh b/scripts/ci/run-act-tests.sh index 884b1e927..c9f752d31 100644 --- a/scripts/ci/run-act-tests.sh +++ b/scripts/ci/run-act-tests.sh @@ -18,6 +18,7 @@ Usage: Jobs: ui + ui-e2e server cli stack @@ -59,7 +60,7 @@ fi mkdir -p "$LOG_DIR" -DEFAULT_JOBS=(ui server cli stack typecheck cli-daemon-e2e e2e-core) +DEFAULT_JOBS=(ui ui-e2e server cli stack typecheck cli-daemon-e2e e2e-core) JOBS=("$@") if [[ ${#JOBS[@]} -eq 0 ]]; then JOBS=("${DEFAULT_JOBS[@]}") diff --git a/scripts/pipeline/checks/lib/checks-profile.mjs b/scripts/pipeline/checks/lib/checks-profile.mjs new file mode 100644 index 000000000..80574fffd --- /dev/null +++ b/scripts/pipeline/checks/lib/checks-profile.mjs @@ -0,0 +1,85 @@ +// @ts-check + +/** + * @typedef {'full'|'fast'|'none'|'custom'} ChecksProfile + * + * @typedef {{ + * runCi: boolean; + * runUiE2e: boolean; + * runE2eCore: boolean; + * runE2eCoreSlow: boolean; + * runServerDbContract: boolean; + * runStress: boolean; + * runBuildWebsite: boolean; + * runBuildDocs: boolean; + * runCliSmokeLinux: boolean; + * }} ChecksProfilePlan + */ + +/** + * @param {unknown} value + * @returns {ChecksProfile} + */ +function parseChecksProfile(value) { + const raw = String(value ?? '').trim(); + if (raw === 'full' || raw === 'fast' || raw === 'none' || raw === 'custom') return raw; + throw new Error(`checks profile must be one of: full, fast, none, custom (got: ${raw || '<empty>'})`); +} + +/** + * @param {string} raw + * @returns {Set<string>} + */ +function parseCustomChecks(raw) { + return new Set( + String(raw ?? '') + .split(',') + .map((v) => v.trim()) + .filter(Boolean), + ); +} + +/** + * Mirrors `.github/workflows/release.yml` check-profile conditional logic. + * + * Notes: + * - `fast` intentionally skips optional lanes (e2e/db-contract/build/smoke). + * - `customChecks` is only honored when `profile=custom`. + * + * @param {{ profile: ChecksProfile; customChecks: string }} input + * @returns {ChecksProfilePlan} + */ +export function resolveChecksProfilePlan(input) { + const profile = parseChecksProfile(input.profile); + const customChecks = profile === 'custom' ? parseCustomChecks(input.customChecks) : new Set(); + + const runCi = profile !== 'none'; + + const isFull = profile === 'full'; + const isFast = profile === 'fast'; + const isCustom = profile === 'custom'; + + const has = (key) => customChecks.has(key); + + const runUiE2e = isFull || isFast || (isCustom && has('ui_e2e')); + const runE2eCore = isFull || (isCustom && (has('e2e_core') || has('e2e_core_slow'))); + const runE2eCoreSlow = isFull || (isCustom && has('e2e_core_slow')); + const runServerDbContract = isFull || (isCustom && has('server_db_contract')); + const runStress = isCustom && has('stress'); + + const runBuildWebsite = isFull || (isCustom && has('build_website')); + const runBuildDocs = isFull || (isCustom && has('build_docs')); + const runCliSmokeLinux = isFull || (isCustom && has('cli_smoke_linux')); + + return { + runCi, + runUiE2e: runCi && runUiE2e, + runE2eCore: runCi && runE2eCore, + runE2eCoreSlow: runCi && runE2eCoreSlow, + runServerDbContract: runCi && runServerDbContract, + runStress: runCi && runStress, + runBuildWebsite: runCi && runBuildWebsite, + runBuildDocs: runCi && runBuildDocs, + runCliSmokeLinux: runCi && runCliSmokeLinux, + }; +} diff --git a/scripts/pipeline/checks/resolve-checks-plan.mjs b/scripts/pipeline/checks/resolve-checks-plan.mjs new file mode 100644 index 000000000..70ba47b7f --- /dev/null +++ b/scripts/pipeline/checks/resolve-checks-plan.mjs @@ -0,0 +1,59 @@ +// @ts-check + +import fs from 'node:fs'; +import { parseArgs } from 'node:util'; + +import { resolveChecksProfilePlan } from './lib/checks-profile.mjs'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {string} outputPath + * @param {Record<string, string>} values + */ +function writeGithubOutput(outputPath, values) { + if (!outputPath) return; + const lines = Object.entries(values).map(([k, v]) => `${k}=${String(v ?? '')}`); + fs.appendFileSync(outputPath, `${lines.join('\n')}\n`, 'utf8'); +} + +function main() { + const { values } = parseArgs({ + options: { + profile: { type: 'string' }, + 'custom-checks': { type: 'string', default: '' }, + 'github-output': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); + + const profile = String(values.profile ?? '').trim(); + if (!profile) fail('--profile is required (full|fast|none|custom)'); + + const customChecks = String(values['custom-checks'] ?? '').trim(); + + const plan = resolveChecksProfilePlan({ + // @ts-expect-error runtime validation happens in resolveChecksProfilePlan + profile, + customChecks, + }); + + writeGithubOutput(String(values['github-output'] ?? '').trim(), { + run_ci: plan.runCi ? 'true' : 'false', + run_ui_e2e: plan.runUiE2e ? 'true' : 'false', + run_e2e_core: plan.runE2eCore ? 'true' : 'false', + run_e2e_core_slow: plan.runE2eCoreSlow ? 'true' : 'false', + run_server_db_contract: plan.runServerDbContract ? 'true' : 'false', + run_stress: plan.runStress ? 'true' : 'false', + run_build_website: plan.runBuildWebsite ? 'true' : 'false', + run_build_docs: plan.runBuildDocs ? 'true' : 'false', + run_cli_smoke_linux: plan.runCliSmokeLinux ? 'true' : 'false', + }); + + process.stdout.write(`${JSON.stringify({ profile, custom_checks: customChecks, ...plan })}\n`); +} + +main(); diff --git a/scripts/pipeline/checks/run-checks.mjs b/scripts/pipeline/checks/run-checks.mjs new file mode 100644 index 000000000..50eca9c57 --- /dev/null +++ b/scripts/pipeline/checks/run-checks.mjs @@ -0,0 +1,142 @@ +// @ts-check + +import { execFileSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; + +import { resolveChecksProfilePlan } from './lib/checks-profile.mjs'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {string} cmd + * @returns {boolean} + */ +function commandExists(cmd) { + try { + const out = execFileSync('bash', ['-lc', `command -v ${cmd} >/dev/null 2>&1 && echo yes || echo no`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + }) + .trim() + .toLowerCase(); + return out === 'yes'; + } catch { + return false; + } +} + +/** + * @param {unknown} value + * @param {string} name + */ +function parseBoolString(value, name) { + const raw = String(value ?? '').trim().toLowerCase(); + if (raw === 'true') return true; + if (raw === 'false') return false; + fail(`${name} must be 'true' or 'false' (got: ${value})`); +} + +/** + * @param {unknown} value + * @param {string} name + * @param {boolean} autoValue + */ +function resolveAutoBool(value, name, autoValue) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw || raw === 'auto') return autoValue; + return parseBoolString(raw, name); +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cmd + * @param {string[]} args + * @param {{ env?: Record<string, string> }} [extra] + */ +function run(opts, cmd, args, extra) { + const printable = `${cmd} ${args.map((a) => (a.includes(' ') ? JSON.stringify(a) : a)).join(' ')}`; + if (opts.dryRun) { + console.log(`[dry-run] ${printable}`); + return; + } + execFileSync(cmd, args, { + env: { ...process.env, ...(extra?.env ?? {}) }, + stdio: 'inherit', + timeout: 4 * 60 * 60_000, + }); +} + +function main() { + const { values } = parseArgs({ + options: { + profile: { type: 'string' }, + 'custom-checks': { type: 'string', default: '' }, + 'install-deps': { type: 'string', default: 'auto' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const profile = String(values.profile ?? '').trim(); + if (!profile) fail('--profile is required (full|fast|none|custom)'); + const customChecks = String(values['custom-checks'] ?? '').trim(); + + const plan = resolveChecksProfilePlan({ + // @ts-expect-error runtime validation happens in resolveChecksProfilePlan + profile, + customChecks, + }); + + const dryRun = values['dry-run'] === true; + const installDeps = resolveAutoBool(values['install-deps'], '--install-deps', process.env.GITHUB_ACTIONS === 'true'); + + console.log(`[pipeline] checks: profile=${profile}`); + console.log('[pipeline] checks: plan'); + for (const [k, v] of Object.entries(plan)) { + console.log(`- ${k}: ${v}`); + } + + if (!plan.runCi) { + console.log('[pipeline] checks: skipped (profile=none)'); + return; + } + + if (installDeps) { + if (commandExists('corepack')) { + run({ dryRun }, 'corepack', ['enable']); + run({ dryRun }, 'corepack', ['prepare', 'yarn@1.22.22', '--activate']); + } + run( + { dryRun }, + 'yarn', + ['install', '--frozen-lockfile', '--ignore-engines'], + { env: { YARN_PRODUCTION: 'false', npm_config_production: 'false' } }, + ); + } + + // Baseline checks (mirrors release workflow intent). + run({ dryRun }, 'yarn', ['test']); + run({ dryRun }, 'yarn', ['test:integration']); + run({ dryRun }, 'yarn', ['typecheck']); + + // UI E2E (Playwright) is now part of the default preflight plan for full/fast. + if (plan.runUiE2e) run({ dryRun }, 'yarn', ['test:e2e:ui']); + + // Release contracts are part of release checks. + run({ dryRun }, 'yarn', ['-s', 'test:release:contracts'], { env: { HAPPIER_FEATURE_POLICY_ENV: '' } }); + run({ dryRun }, process.execPath, ['scripts/pipeline/run.mjs', 'release-sync-installers', '--check']); + + if (plan.runE2eCore) run({ dryRun }, 'yarn', ['test:e2e:core:fast']); + if (plan.runE2eCoreSlow) run({ dryRun }, 'yarn', ['test:e2e:core:slow']); + if (plan.runServerDbContract) run({ dryRun }, 'yarn', ['test:db-contract:docker']); + if (plan.runStress) run({ dryRun }, 'yarn', ['test:stress']); + if (plan.runBuildWebsite) run({ dryRun }, 'yarn', ['website:build']); + if (plan.runBuildDocs) run({ dryRun }, 'yarn', ['docs:build']); + if (plan.runCliSmokeLinux) run({ dryRun }, process.execPath, ['scripts/pipeline/run.mjs', 'smoke-cli']); +} + +main(); diff --git a/scripts/pipeline/cli/ansi-style.mjs b/scripts/pipeline/cli/ansi-style.mjs new file mode 100644 index 000000000..f6f0c9afe --- /dev/null +++ b/scripts/pipeline/cli/ansi-style.mjs @@ -0,0 +1,38 @@ +// @ts-check + +/** + * @typedef {{ enabled: boolean }} AnsiStyleOptions + */ + +/** + * @param {AnsiStyleOptions} opts + */ +export function createAnsiStyle(opts) { + const enabled = opts.enabled === true; + const RESET = '\u001b[0m'; + const BOLD = '\u001b[1m'; + const DIM = '\u001b[2m'; + const CYAN = '\u001b[36m'; + const GREEN = '\u001b[32m'; + const YELLOW = '\u001b[33m'; + const RED = '\u001b[31m'; + + /** + * @param {string} code + * @param {string} text + */ + function wrap(code, text) { + return enabled ? `${code}${text}${RESET}` : text; + } + + return { + enabled, + bold: (text) => wrap(BOLD, text), + dim: (text) => wrap(DIM, text), + cyan: (text) => wrap(CYAN, text), + green: (text) => wrap(GREEN, text), + yellow: (text) => wrap(YELLOW, text), + red: (text) => wrap(RED, text), + }; +} + diff --git a/scripts/pipeline/cli/help-specs.mjs b/scripts/pipeline/cli/help-specs.mjs new file mode 100644 index 000000000..91212661b --- /dev/null +++ b/scripts/pipeline/cli/help-specs.mjs @@ -0,0 +1,43 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +import { COMMAND_HELP_ORCHESTRATORS } from './help-specs/orchestrators.mjs'; +import { COMMAND_HELP_CHECKS } from './help-specs/checks.mjs'; +import { COMMAND_HELP_NPM } from './help-specs/npm.mjs'; +import { COMMAND_HELP_DOCKER } from './help-specs/docker.mjs'; +import { COMMAND_HELP_PUBLISH } from './help-specs/publish.mjs'; +import { COMMAND_HELP_EXPO } from './help-specs/expo.mjs'; +import { COMMAND_HELP_TAURI } from './help-specs/tauri.mjs'; +import { COMMAND_HELP_GITHUB } from './help-specs/github.mjs'; +import { COMMAND_HELP_RELEASE_INTERNALS } from './help-specs/release-internals.mjs'; +import { COMMAND_HELP_MISC } from './help-specs/misc.mjs'; + +/** + * NOTE: + * - Keep specs operator-focused and self-contained. + * - Prefer listing every supported flag in `options`, even for "advanced" subcommands. + * + * @type {Record<string, CommandHelpSpec>} + */ +export const COMMAND_HELP = { + ...COMMAND_HELP_ORCHESTRATORS, + ...COMMAND_HELP_CHECKS, + ...COMMAND_HELP_NPM, + ...COMMAND_HELP_DOCKER, + ...COMMAND_HELP_PUBLISH, + ...COMMAND_HELP_EXPO, + ...COMMAND_HELP_TAURI, + ...COMMAND_HELP_GITHUB, + ...COMMAND_HELP_RELEASE_INTERNALS, + ...COMMAND_HELP_MISC, +}; + diff --git a/scripts/pipeline/cli/help-specs/checks.mjs b/scripts/pipeline/cli/help-specs/checks.mjs new file mode 100644 index 000000000..590f2061c --- /dev/null +++ b/scripts/pipeline/cli/help-specs/checks.mjs @@ -0,0 +1,66 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_CHECKS = { + 'checks-plan': { + summary: 'Print the resolved CI check plan for a given profile.', + usage: + 'node scripts/pipeline/run.mjs checks-plan --profile <none|fast|full|custom> [--custom-checks <csv>] [--github-output <path>] [--dry-run]', + options: [ + '--profile <name> Required; none|fast|full|custom.', + '--custom-checks <csv> Required when --profile custom.', + '--github-output <path> Optional; writes KEY=VALUE lines for Actions.', + '--dry-run', + ], + bullets: ['Useful to see exactly what `checks` would run before you execute it.'], + examples: [ + 'node scripts/pipeline/run.mjs checks-plan --profile fast', + 'node scripts/pipeline/run.mjs checks-plan --profile custom --custom-checks e2e_core,build_docs', + ], + }, + + checks: { + summary: 'Run the local CI check suite (parity with GitHub Actions).', + usage: + 'node scripts/pipeline/run.mjs checks --profile <none|fast|full|custom> [--custom-checks <csv>] [--install-deps <auto|true|false>] [--dry-run]', + options: [ + '--profile <name> Required; none|fast|full|custom.', + '--custom-checks <csv> Required when --profile custom.', + '--install-deps <auto|true|false> (default: auto).', + '--dry-run', + ], + bullets: [ + 'Use this when iterating on CI locally instead of waiting for GitHub runners.', + 'Uses your local toolchain; if GitHub differs, prefer running checks in a clean container/VM.', + ], + examples: [ + 'node scripts/pipeline/run.mjs checks --profile fast', + 'node scripts/pipeline/run.mjs checks --profile custom --custom-checks e2e_core_slow,server_db_contract', + ], + }, + + 'smoke-cli': { + summary: 'Run the CLI smoke test (sanity-check a built CLI package).', + usage: + 'node scripts/pipeline/run.mjs smoke-cli [--package-dir <dir>] [--workspace-name <name>] [--skip-build true|false] [--dry-run]', + options: [ + '--package-dir <dir> (default: apps/cli).', + '--workspace-name <name> (default: @happier-dev/cli).', + '--skip-build <bool> true|false (default: false).', + '--dry-run', + ], + bullets: ['Useful before publishing npm packages or CLI binaries.'], + examples: ['node scripts/pipeline/run.mjs smoke-cli --skip-build false'], + }, +}; + diff --git a/scripts/pipeline/cli/help-specs/docker.mjs b/scripts/pipeline/cli/help-specs/docker.mjs new file mode 100644 index 000000000..b3a0b61d9 --- /dev/null +++ b/scripts/pipeline/cli/help-specs/docker.mjs @@ -0,0 +1,39 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_DOCKER = { + 'docker-publish': { + summary: 'Build and publish multi-arch Docker images (Docker Hub + optional GHCR).', + usage: + 'node scripts/pipeline/run.mjs docker-publish --channel <preview|stable> [--registries <csv>] [--sha <sha>] [--dry-run]', + options: [ + '--channel <preview|stable> Required.', + '--registries <csv> e.g. dockerhub,ghcr (default: env/auto).', + '--sha <sha> Optional; override tag SHA.', + '--push-latest <bool> true|false (default: true).', + '--build-relay <bool> true|false (default: true).', + '--build-dev-box <bool> true|false (default: true).', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: [ + 'Uses Docker buildx; ensure Docker Desktop is running.', + 'GHCR publishing uses your `gh` auth; Docker Hub uses DOCKERHUB_USERNAME/DOCKERHUB_TOKEN.', + ], + examples: ['node scripts/pipeline/run.mjs docker-publish --channel preview --registries dockerhub,ghcr --dry-run'], + }, +}; + diff --git a/scripts/pipeline/cli/help-specs/expo.mjs b/scripts/pipeline/cli/help-specs/expo.mjs new file mode 100644 index 000000000..4e6a67feb --- /dev/null +++ b/scripts/pipeline/cli/help-specs/expo.mjs @@ -0,0 +1,165 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_EXPO = { + 'ui-mobile-release': { + summary: 'Expo mobile release entrypoint (OTA, native build, submit).', + usage: + 'node scripts/pipeline/run.mjs ui-mobile-release --environment <preview|production> --action <ota|native|native_submit> --platform <ios|android|all> [--profile <easProfile>]', + options: [ + '--environment <preview|production> Required.', + '--action <ota|native|native_submit> Required.', + '--platform <ios|android|all> Required.', + '--profile <name> Required for native/native_submit; must start with preview* or production*.', + '--publish-apk-release <auto|true|false> (default: auto).', + '--native-build-mode <cloud|local> (default: cloud).', + '--native-local-runtime <host|dagger> (default: host).', + '--build-json <path> (default: /tmp/eas_build.json).', + '--out-dir <dir> (default: dist/ui-mobile).', + '--eas-cli-version <ver> Optional; pins EAS CLI.', + '--dump-view <bool> Optional; debug EAS build view.', + '--release-message <text> Optional; passed to APK release publish.', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: [ + 'This command composes expo-ota / expo-native-build / expo-submit for convenience.', + "For local iOS builds, use --native-build-mode local and keep --native-local-runtime host (requires Xcode).", + 'For local Android builds, you may use --native-local-runtime dagger for containerized reproducibility.', + ], + examples: [ + 'node scripts/pipeline/run.mjs ui-mobile-release --environment preview --action ota --platform all', + 'node scripts/pipeline/run.mjs ui-mobile-release --environment production --action native --platform ios --profile production --native-build-mode local --native-local-runtime host', + ], + }, + + 'expo-ota': { + summary: 'Publish an Expo OTA update for the given environment.', + usage: 'node scripts/pipeline/run.mjs expo-ota --environment <preview|production> [--message <text>] [--dry-run]', + options: [ + '--environment <preview|production> Required.', + '--message <text> Optional.', + '--eas-cli-version <ver> Optional; pins EAS CLI.', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Requires Expo auth (EXPO_TOKEN or EAS local login).'], + examples: ['node scripts/pipeline/run.mjs expo-ota --environment preview --message "Preview OTA"'], + }, + + 'expo-native-build': { + summary: 'Build a native Expo app (EAS Build) and write build metadata to a JSON file.', + usage: + 'node scripts/pipeline/run.mjs expo-native-build --platform <ios|android> --profile <profile> --out <buildJsonPath> [--build-mode cloud|local] [--artifact-out <path>]', + options: [ + '--platform <ios|android> Required.', + '--profile <name> Required; EAS build profile.', + '--out <path> Required; build JSON output path.', + '--build-mode <cloud|local> Optional; overrides profile runner.', + '--local-runtime <host|dagger> Optional; only applies to local builds.', + '--artifact-out <path> Optional; writes IPA/AAB/APK to this path for local builds.', + '--eas-cli-version <ver> Optional; pins EAS CLI.', + '--dump-view <bool> true|false (default: true).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Use ui-mobile-release if you want a higher-level flow (build + submit).'], + examples: [ + 'node scripts/pipeline/run.mjs expo-native-build --platform ios --profile production --out /tmp/eas_build.ios.json --build-mode local --local-runtime host --artifact-out dist/ui-mobile/happier-production-ios.ipa', + ], + }, + + 'expo-download-apk': { + summary: 'Download the Android APK from a previous EAS Build JSON output.', + usage: + 'node scripts/pipeline/run.mjs expo-download-apk --environment <preview|production> [--build-json <path>] [--out-dir <dir>]', + options: [ + '--environment <preview|production> Required.', + '--build-json <path> (default: /tmp/eas_build.json).', + '--out-dir <dir> (default: dist/ui-mobile).', + '--eas-cli-version <ver> Optional; pins EAS CLI.', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Only relevant for *-apk EAS profiles.'], + examples: ['node scripts/pipeline/run.mjs expo-download-apk --environment preview --build-json /tmp/eas_build.json'], + }, + + 'expo-mobile-meta': { + summary: 'Compute/emit mobile release metadata (used by workflows).', + usage: + 'node scripts/pipeline/run.mjs expo-mobile-meta --environment <preview|production> [--download-ok true|false] [--out-json <path>]', + options: [ + '--environment <preview|production> Required.', + '--download-ok <bool> true|false (default: false).', + '--app-version <semver> Optional override.', + '--out-json <path> Optional; write JSON metadata to a file.', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Mostly used internally by release automation.'], + examples: ['node scripts/pipeline/run.mjs expo-mobile-meta --environment production --out-json dist/ui-mobile/meta.json'], + }, + + 'expo-submit': { + summary: 'Submit a native build to TestFlight / Play Store (EAS Submit).', + usage: + 'node scripts/pipeline/run.mjs expo-submit --environment <preview|production> --platform <ios|android|all> [--profile <submitProfile>] [--path <artifactPath>]', + options: [ + '--environment <preview|production> Required.', + '--platform <ios|android|all> Required.', + '--profile <name> Optional; EAS submit profile.', + '--path <path> Optional; submit a local artifact (IPA/AAB/APK).', + '--eas-cli-version <ver> Optional; pins EAS CLI.', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Use --path to submit a locally-built artifact.'], + examples: [ + 'node scripts/pipeline/run.mjs expo-submit --environment production --platform ios --profile production --path dist/ui-mobile/happier-production-ios-v0.1.0.ipa', + ], + }, + + 'expo-publish-apk-release': { + summary: 'Publish an Android APK asset as a GitHub Release (used for preview distribution).', + usage: + 'node scripts/pipeline/run.mjs expo-publish-apk-release --environment <preview|production> --apk-path <path> --target-sha <sha> [--release-message <text>]', + options: [ + '--environment <preview|production> Required.', + '--apk-path <path> Required.', + '--target-sha <sha> Required.', + '--release-message <text> Optional.', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Used by ui-mobile-release when building APK profiles.'], + examples: [ + 'node scripts/pipeline/run.mjs expo-publish-apk-release --environment preview --apk-path dist/ui-mobile/happier-preview-android.apk --target-sha $(git rev-parse HEAD)', + ], + }, +}; + diff --git a/scripts/pipeline/cli/help-specs/github.mjs b/scripts/pipeline/cli/help-specs/github.mjs new file mode 100644 index 000000000..be3be2463 --- /dev/null +++ b/scripts/pipeline/cli/help-specs/github.mjs @@ -0,0 +1,84 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_GITHUB = { + 'github-publish-release': { + summary: 'Create/update a GitHub Release and upload assets (supports rolling tags).', + usage: + 'node scripts/pipeline/run.mjs github-publish-release --tag <tag> --title <title> --target-sha <sha> [--assets <csv>] [--assets-dir <dir>] [--dry-run]', + options: [ + '--tag <tag> Required.', + '--title <title> Required.', + '--target-sha <sha> Required.', + '--prerelease <bool> true|false (required by caller).', + '--rolling-tag <tag> Optional; update a moving tag too.', + '--generate-notes <bool> true|false.', + '--notes <text> Optional release notes body.', + '--assets <csv> Optional list of filenames.', + '--assets-dir <dir> Optional; uploads all matching assets from dir.', + '--clobber <bool> true|false (default: true).', + '--prune-assets <bool> true|false (default: false).', + '--release-message <text> Optional; appended to notes.', + '--max-commits <n> (default: 200).', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Requires GitHub CLI auth (`gh auth status`).'], + examples: [ + 'node scripts/pipeline/run.mjs github-publish-release --tag cli-preview --title "CLI Preview" --target-sha $(git rev-parse HEAD) --prerelease true --dry-run', + ], + }, + + 'github-audit-release-assets': { + summary: 'Audit that a release has the expected assets (contract check).', + usage: + 'node scripts/pipeline/run.mjs github-audit-release-assets --tag <tag> --kind <kind> [--version <ver>] [--targets <csv>] [--repo <owner/repo>]', + options: [ + '--tag <tag> Required.', + '--kind <kind> Required; validation profile (internal).', + '--version <ver> Optional; expected version.', + '--targets <csv> Optional; expected platform targets.', + '--repo <owner/repo> Optional.', + '--assets-json <path> Optional; pre-fetched assets JSON.', + '--dry-run', + ], + bullets: ['Used by CI to ensure releases are complete and installers can find assets.'], + examples: ['node scripts/pipeline/run.mjs github-audit-release-assets --tag cli-preview --kind cli'], + }, + + 'github-commit-and-push': { + summary: 'Commit selected paths and push to a remote ref (workflow helper).', + usage: + 'node scripts/pipeline/run.mjs github-commit-and-push --paths <csv> --message <msg> --push-ref <ref> [--dry-run]', + options: [ + '--paths <csv> Comma-separated paths to `git add`.', + '--allow-missing <bool> true|false (default: false).', + '--message <msg> Commit message.', + '--author-name <name> Optional override.', + '--author-email <email> Optional override.', + '--remote <name> Optional.', + '--push-ref <ref> Optional; e.g. HEAD:dev.', + '--push-mode <mode> Optional; internal.', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + ], + bullets: ['Prefer normal git workflows for day-to-day work; use this when you need CI parity.'], + examples: [ + 'node scripts/pipeline/run.mjs github-commit-and-push --paths apps/ui/package.json --message "chore: bump ui" --push-ref HEAD:dev --dry-run', + ], + }, +}; + diff --git a/scripts/pipeline/cli/help-specs/misc.mjs b/scripts/pipeline/cli/help-specs/misc.mjs new file mode 100644 index 000000000..d2b996a7e --- /dev/null +++ b/scripts/pipeline/cli/help-specs/misc.mjs @@ -0,0 +1,32 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_MISC = { + 'testing-create-auth-credentials': { + summary: 'Create local auth credential files for testing (helper).', + usage: + 'node scripts/pipeline/run.mjs testing-create-auth-credentials [--server-url <url>] [--home-dir <dir>] [--active-server-id <id>] [--secret-base64 <b64>]', + options: [ + '--server-url <url> Optional.', + '--home-dir <dir> Optional.', + '--active-server-id <id> Optional.', + '--secret-base64 <b64> Optional.', + '--dry-run', + ], + bullets: ['Used by e2e suites; avoid checking generated secrets into git.'], + examples: [ + 'node scripts/pipeline/run.mjs testing-create-auth-credentials --server-url http://localhost:3000 --home-dir /tmp/happier-home', + ], + }, +}; + diff --git a/scripts/pipeline/cli/help-specs/npm.mjs b/scripts/pipeline/cli/help-specs/npm.mjs new file mode 100644 index 000000000..78ecfe798 --- /dev/null +++ b/scripts/pipeline/cli/help-specs/npm.mjs @@ -0,0 +1,78 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_NPM = { + 'npm-release': { + summary: 'Pack and publish npm packages (CLI / stack / relay-server).', + usage: + 'node scripts/pipeline/run.mjs npm-release --channel <preview|production> --publish-cli <true|false> --publish-stack <true|false> --publish-server <true|false> [--mode pack|pack+publish]', + options: [ + '--channel <preview|production> Required.', + '--publish-cli <bool> Publish apps/cli (default: false).', + '--publish-stack <bool> Publish apps/stack (default: false).', + '--publish-server <bool> Publish packages/relay-server (default: false).', + '--server-runner-dir <dir> (default: packages/relay-server).', + '--run-tests <auto|true|false> (default: auto).', + '--mode <pack|pack+publish> (default: pack+publish).', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: [ + 'Preview publishes temporary versions (no commit) using a preview suffix (X.Y.Z-preview.<run>.<attempt>).', + 'Local auth: uses NPM_TOKEN if set, otherwise falls back to your local npm login state.', + ], + examples: [ + 'node scripts/pipeline/run.mjs npm-release --channel preview --publish-cli true --publish-stack true --mode pack+publish', + 'node scripts/pipeline/run.mjs npm-release --channel preview --publish-server true --mode pack+publish', + ], + }, + + 'npm-publish': { + summary: 'Publish a pre-built .tgz tarball to npm (lower-level helper).', + usage: + 'node scripts/pipeline/run.mjs npm-publish --channel <preview|production> (--tarball <path>|--tarball-dir <dir>) [--tag <distTag>] [--dry-run]', + options: [ + '--channel <preview|production> Required.', + '--tarball <path> A single `.tgz` file to publish.', + '--tarball-dir <dir> Publish all `.tgz` files in the directory.', + '--tag <distTag> Optional npm dist-tag override.', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Usually used by npm-release; use directly only when you already have a tarball.'], + examples: ['node scripts/pipeline/run.mjs npm-publish --channel preview --tarball dist/release-assets/cli/happier-cli.tgz --dry-run'], + }, + + 'npm-set-preview-versions': { + summary: 'Compute (and optionally write) preview versions into package.json files.', + usage: + 'node scripts/pipeline/run.mjs npm-set-preview-versions --publish-cli <true|false> --publish-stack <true|false> --publish-server <true|false> [--write true|false]', + options: [ + '--repo-root <path> Optional override.', + '--publish-cli <bool> (default: false).', + '--publish-stack <bool> (default: false).', + '--publish-server <bool> (default: false).', + '--server-runner-dir <dir> (default: packages/relay-server).', + '--write <bool> true|false (default: true).', + ], + bullets: ['Mainly used internally by npm-release / release; most operators should use npm-release.'], + examples: ['node scripts/pipeline/run.mjs npm-set-preview-versions --publish-cli true --publish-stack true --write false'], + }, +}; + diff --git a/scripts/pipeline/cli/help-specs/orchestrators.mjs b/scripts/pipeline/cli/help-specs/orchestrators.mjs new file mode 100644 index 000000000..3b6220cfe --- /dev/null +++ b/scripts/pipeline/cli/help-specs/orchestrators.mjs @@ -0,0 +1,121 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_ORCHESTRATORS = { + release: { + summary: 'Orchestrate a full preview/production release (recommended entrypoint).', + usage: + 'node scripts/pipeline/run.mjs release --confirm <action> --repository <owner/repo> [--deploy-environment preview|production] [--deploy-targets <csv>] [--dry-run]', + options: [ + '--confirm <action> Required safety confirmation.', + '--repository <owner/repo> Required; e.g. happier-dev/happier.', + "--deploy-environment <env> preview|production (default: preview).", + '--deploy-targets <csv> ui,server,website,docs,cli,stack,server_runner (default: ui,server,website,docs).', + '--force-deploy <bool> true|false (default: false).', + '--bump <preset> none|patch|minor|major (default: none).', + '--bump-app-override <preset> none|patch|minor|major|preset (default: preset).', + '--bump-cli-override <preset> none|patch|minor|major|preset (default: preset).', + '--bump-stack-override <preset> none|patch|minor|major|preset (default: preset).', + '--ui-expo-action <mode> none|ota|native|native_submit (default: none).', + '--ui-expo-builder <builder> eas_cloud|eas_local (default: eas_cloud).', + '--ui-expo-profile <profile> auto|preview|preview-apk|production|production-apk (default: auto).', + '--ui-expo-platform <p> ios|android|all (default: all).', + '--desktop-mode <mode> none|build_only|build_and_publish (default: none).', + '--release-message <text> Optional; included in GitHub releases.', + '--npm-mode <mode> pack|pack+publish (default: pack+publish).', + '--npm-run-tests <mode> auto|true|false (default: auto).', + '--npm-server-runner-dir <dir> (default: packages/relay-server).', + '--sync-dev-from-main <bool> true|false (default: true).', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run Print intended actions without mutating.', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: [ + 'Computes a release plan (changed components) then executes publish steps.', + 'Refuses to publish from a dirty worktree by default (use --allow-dirty true when intentional).', + 'Use --dry-run first; once green, re-run without --dry-run to execute.', + ], + examples: [ + 'node scripts/pipeline/run.mjs release --confirm "release preview from dev" --repository happier-dev/happier --deploy-environment preview --dry-run', + 'node scripts/pipeline/run.mjs release --confirm "release preview from dev" --repository happier-dev/happier --deploy-environment preview', + ], + }, + + deploy: { + summary: 'Trigger deploy webhook(s) for a hosted surface (server/ui/website/docs).', + usage: + 'node scripts/pipeline/run.mjs deploy --deploy-environment <preview|production> --component <ui|server|website|docs> [--repository <owner/repo>] [--ref-name <ref>] [--sha <sha>] [--dry-run]', + options: [ + '--deploy-environment <env> preview|production (default: production).', + '--component <name> ui|server|website|docs (required).', + '--repository <owner/repo> Optional; falls back to GITHUB_REPOSITORY env.', + '--ref-name <ref> Ref to deploy (default: deploy/<env>/<component>).', + '--sha <sha> Optional; passed through for auditing.', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Deploy branches are `deploy/<env>/<component>`.'], + examples: [ + 'node scripts/pipeline/run.mjs deploy --deploy-environment production --component website --repository happier-dev/happier', + ], + }, + + 'promote-branch': { + summary: 'Promote one branch to another (fast-forward or reset) via GitHub API.', + usage: + 'node scripts/pipeline/run.mjs promote-branch --source <branch> --target <branch> --mode <fast_forward|reset> --confirm <string> [--allow-reset true|false] [--summary-file <path>] [--dry-run]', + options: [ + '--source <branch> Required; e.g. dev.', + '--target <branch> Required; e.g. main.', + '--mode <fast_forward|reset> Required.', + '--confirm <text> Required safety text (free-form).', + '--allow-reset <bool> Required for --mode reset (default: false).', + '--summary-file <path> Optional; append markdown summary (Actions: $GITHUB_STEP_SUMMARY).', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Requires GitHub CLI auth (`gh auth status`).'], + examples: [ + 'node scripts/pipeline/run.mjs promote-branch --source dev --target main --mode fast_forward --confirm "promote main from dev" --dry-run', + ], + }, + + 'promote-deploy-branch': { + summary: 'Update a remote deploy branch to a source ref or SHA via GitHub API.', + usage: + 'node scripts/pipeline/run.mjs promote-deploy-branch --deploy-environment <preview|production> --component <ui|server|website|docs> [--source-ref <ref>] [--sha <sha>] [--summary-file <path>] [--dry-run]', + options: [ + '--deploy-environment <env> preview|production (required).', + '--component <name> ui|server|website|docs (required).', + '--source-ref <ref> Optional; e.g. dev or main.', + '--sha <sha> Optional; exact commit SHA (alternative to --source-ref).', + '--summary-file <path> Optional GitHub Step Summary output path.', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Requires GitHub CLI auth (`gh auth status`).'], + examples: [ + 'node scripts/pipeline/run.mjs promote-deploy-branch --deploy-environment production --component website --source-ref dev', + ], + }, +}; diff --git a/scripts/pipeline/cli/help-specs/publish.mjs b/scripts/pipeline/cli/help-specs/publish.mjs new file mode 100644 index 000000000..ea4b81a58 --- /dev/null +++ b/scripts/pipeline/cli/help-specs/publish.mjs @@ -0,0 +1,98 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_PUBLISH = { + 'publish-cli-binaries': { + summary: 'Build + publish CLI binaries to GitHub Releases (rolling + version tags).', + usage: + 'node scripts/pipeline/run.mjs publish-cli-binaries --channel <preview|stable> [--release-message <text>] [--dry-run]', + options: [ + '--channel <preview|stable> Required.', + '--allow-stable <bool> true|false (default: false).', + '--release-message <text> Optional.', + '--run-contracts <auto|true|false> (default: auto).', + '--check-installers <bool> true|false (default: true).', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: [ + 'Requires MINISIGN_SECRET_KEY (+ MINISIGN_PASSPHRASE if encrypted).', + 'Publishes a rolling tag (cli-preview/cli-stable) and a versioned tag (cli-vX.Y.Z...).', + ], + examples: ['node scripts/pipeline/run.mjs publish-cli-binaries --channel preview --release-message "CLI preview"'], + }, + + 'publish-hstack-binaries': { + summary: 'Build + publish hstack binaries to GitHub Releases (rolling + version tags).', + usage: + 'node scripts/pipeline/run.mjs publish-hstack-binaries --channel <preview|stable> [--release-message <text>] [--dry-run]', + options: [ + '--channel <preview|stable> Required.', + '--allow-stable <bool> true|false (default: false).', + '--release-message <text> Optional.', + '--run-contracts <auto|true|false> (default: auto).', + '--check-installers <bool> true|false (default: true).', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Requires MINISIGN_SECRET_KEY (+ MINISIGN_PASSPHRASE if encrypted).'], + examples: ['node scripts/pipeline/run.mjs publish-hstack-binaries --channel preview --release-message "Stack preview"'], + }, + + 'publish-server-runtime': { + summary: 'Build + publish relay-server (server runner) runtime binaries to GitHub Releases.', + usage: + 'node scripts/pipeline/run.mjs publish-server-runtime --channel <preview|stable> [--release-message <text>] [--dry-run]', + options: [ + '--channel <preview|stable> Required.', + '--allow-stable <bool> true|false (default: false).', + '--release-message <text> Optional.', + '--run-contracts <auto|true|false> (default: auto).', + '--check-installers <bool> true|false (default: true).', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Requires MINISIGN_SECRET_KEY (+ MINISIGN_PASSPHRASE if encrypted).'], + examples: ['node scripts/pipeline/run.mjs publish-server-runtime --channel preview --release-message "Relay server preview"'], + }, + + 'publish-ui-web': { + summary: 'Build + publish the UI web bundle as GitHub release assets.', + usage: + 'node scripts/pipeline/run.mjs publish-ui-web --channel <preview|stable> [--release-message <text>] [--dry-run]', + options: [ + '--channel <preview|stable> Required.', + '--allow-stable <bool> true|false (default: false).', + '--release-message <text> Optional.', + '--run-contracts <auto|true|false> (default: auto).', + '--check-installers <bool> true|false (default: true).', + '--allow-dirty <bool> true|false (default: false).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Publishes a rolling tag and a versioned tag for the UI web bundle assets.'], + examples: ['node scripts/pipeline/run.mjs publish-ui-web --channel preview --release-message "UI web preview"'], + }, +}; + diff --git a/scripts/pipeline/cli/help-specs/release-internals.mjs b/scripts/pipeline/cli/help-specs/release-internals.mjs new file mode 100644 index 000000000..efcd07d96 --- /dev/null +++ b/scripts/pipeline/cli/help-specs/release-internals.mjs @@ -0,0 +1,304 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** + * Wrapper flags (owned by `run.mjs`) for `release-*` wrapped scripts: + * - `--deploy-environment <preview|production>` + * - `--dry-run` + * - `--secrets-source <auto|env|keychain>` + * - `--keychain-service <name>` + * - `--keychain-account <name>` + * + * All other flags are forwarded verbatim to the underlying script in `scripts/pipeline/release/*`. + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_RELEASE_INTERNALS = { + 'release-bump-plan': { + summary: 'Compute a bump plan from “changed components” inputs (workflow helper).', + usage: + 'node scripts/pipeline/run.mjs release-bump-plan --environment <preview|production> --bump-preset <none|patch|minor|major> [--deploy-targets <csv>]', + options: [ + '--environment <preview|production> Required.', + '--bump-preset <preset> Required; none|patch|minor|major.', + '--bump-app-override <preset> (default: preset).', + '--bump-cli-override <preset> (default: preset).', + '--bump-stack-override <preset> (default: preset).', + '--deploy-targets <csv> Optional.', + '--changed-ui <bool>', + '--changed-cli <bool>', + '--changed-stack <bool>', + '--changed-server <bool>', + '--changed-website <bool>', + '--changed-shared <bool>', + ], + bullets: ['Most operators should use `release`, not this subcommand.'], + examples: [ + 'node scripts/pipeline/run.mjs release-bump-plan --environment preview --bump-preset patch --changed-ui true --changed-server true', + ], + }, + + 'release-bump-versions-dev': { + summary: 'Bump selected component versions and push a commit to a branch (CI helper).', + usage: + 'node scripts/pipeline/run.mjs release-bump-versions-dev [--bump-app <bump>] [--bump-server <bump>] [--push-branch <branch>] [--dry-run]', + options: [ + '--bump-app <none|patch|minor|major> (default: none).', + '--bump-server <none|patch|minor|major> (default: none).', + '--bump-website <none|patch|minor|major> (default: none).', + '--bump-cli <none|patch|minor|major> (default: none).', + '--bump-stack <none|patch|minor|major> (default: none).', + '--push-branch <branch> (default: dev).', + '--commit-message <text> Optional.', + '--dry-run', + ], + bullets: ['Used by release workflows to prepare version bumps on dev/main.'], + examples: [ + 'node scripts/pipeline/run.mjs release-bump-versions-dev --bump-cli patch --bump-stack patch --push-branch dev --dry-run', + ], + }, + + 'release-sync-installers': { + summary: 'Sync installer scripts from scripts/release/installers into apps/website/public (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-sync-installers [--deploy-environment <preview|production>] [--check] [--source-dir <dir>] [--target-dir <dir>]', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag (default: happier/pipeline).', + '--keychain-account <name> Wrapper flag.', + '--dry-run Wrapper flag.', + '--check Script flag; fail if installers are out of sync.', + '--source-dir <dir> Script flag (default: scripts/release/installers).', + '--target-dir <dir> Script flag (default: apps/website/public).', + ], + bullets: ['In most flows, publish-* commands validate installers automatically (via --check-installers).'], + examples: ['node scripts/pipeline/run.mjs release-sync-installers --check --dry-run'], + }, + + 'release-bump-version': { + summary: 'Bump a single component version in-place (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-bump-version --component <app|cli|server|website|stack> --bump <none|patch|minor|major>', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag (default: happier/pipeline).', + '--keychain-account <name> Wrapper flag.', + '--dry-run Wrapper flag.', + '--component <name> Script flag (required).', + '--bump <kind> Script flag (required).', + ], + bullets: ['Updates app version across Expo + Tauri config when component=app.'], + examples: ['node scripts/pipeline/run.mjs release-bump-version --component cli --bump patch'], + }, + + 'release-build-cli-binaries': { + summary: 'Build CLI binary artifacts + minisign checksums (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-build-cli-binaries --channel <preview|stable> [--version <ver>] [--targets <csv>] [--externals <csv>]', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag (default: happier/pipeline).', + '--keychain-account <name> Wrapper flag.', + '--dry-run Wrapper flag.', + '--channel <preview|stable> Script flag.', + '--version <ver> Script flag; defaults to apps/cli package.json.', + '--targets <csv> Script flag.', + '--externals <csv> Script flag; bun externals.', + ], + bullets: ['Requires bun + minisign (for signatures).'], + examples: ['node scripts/pipeline/run.mjs release-build-cli-binaries --channel preview --targets linux-x64,linux-arm64'], + }, + + 'release-build-hstack-binaries': { + summary: 'Build hstack binary artifacts + minisign checksums (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-build-hstack-binaries --channel <preview|stable> [--version <ver>] [--entrypoint <path>] [--targets <csv>]', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag (default: happier/pipeline).', + '--keychain-account <name> Wrapper flag.', + '--dry-run Wrapper flag.', + '--channel <preview|stable> Script flag.', + '--version <ver> Script flag; defaults to apps/stack package.json.', + '--entrypoint <path> Script flag; defaults to apps/stack/scripts/self_host.mjs.', + '--targets <csv> Script flag.', + '--externals <csv> Script flag.', + ], + bullets: ['Requires bun + minisign (for signatures).'], + examples: ['node scripts/pipeline/run.mjs release-build-hstack-binaries --channel preview --targets darwin-arm64'], + }, + + 'release-build-server-binaries': { + summary: 'Build server binary artifacts + minisign checksums (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-build-server-binaries --channel <preview|stable> [--version <ver>] [--entrypoint <path>] [--targets <csv>]', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag (default: happier/pipeline).', + '--keychain-account <name> Wrapper flag.', + '--dry-run Wrapper flag.', + '--channel <preview|stable> Script flag.', + '--version <ver> Script flag; defaults to apps/server package.json.', + '--entrypoint <path> Script flag; defaults to apps/server/sources/main.light.ts.', + '--targets <csv> Script flag.', + '--externals <csv> Script flag; bun externals.', + ], + bullets: ['Ensures Prisma clients are generated before compiling.'], + examples: ['node scripts/pipeline/run.mjs release-build-server-binaries --channel preview --targets linux-x64'], + }, + + 'release-publish-manifests': { + summary: 'Generate “latest.json” manifest(s) for a product/channel (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-publish-manifests --product <happier|hstack|happier-server> --channel <preview|stable> --assets-base-url <url> [--artifacts-dir <dir>]', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag (default: happier/pipeline).', + '--keychain-account <name> Wrapper flag.', + '--dry-run Wrapper flag.', + '--product <name> Script flag (required).', + '--channel <preview|stable> Script flag (required).', + '--assets-base-url <url> Script flag (required).', + '--artifacts-dir <dir> Script flag (default: dist/release-assets).', + '--out-dir <dir> Script flag (default: dist/manifests).', + '--rollout-percent <n> Script flag (default: 100).', + '--critical <bool> Script flag (default: false).', + '--notes-url <url> Script flag (optional).', + '--min-supported-version <ver> Script flag (optional).', + '--version <ver> Script flag (optional).', + '--commit-sha <sha> Script flag (optional).', + '--workflow-run-id <id> Script flag (optional).', + ], + bullets: ['Manifests are consumed by installer scripts and self-host tooling.'], + examples: [ + 'node scripts/pipeline/run.mjs release-publish-manifests --product happier --channel preview --assets-base-url https://github.com/happier-dev/happier/releases/download/cli-preview', + ], + }, + + 'release-verify-artifacts': { + summary: 'Verify checksums/signatures and optionally smoke-test release artifacts (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-verify-artifacts [--artifacts-dir <dir>] [--checksums <path>] [--public-key <path>] [--skip-smoke]', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag (default: happier/pipeline).', + '--keychain-account <name> Wrapper flag.', + '--dry-run Wrapper flag.', + '--artifacts-dir <dir> Script flag (default: dist/release-assets).', + '--checksums <path> Script flag; defaults to first checksums-*.txt found.', + '--public-key <path> Script flag; or set MINISIGN_PUBLIC_KEY.', + '--skip-smoke Script flag.', + ], + bullets: ['This is safety-critical; prefer running it in CI in addition to local runs.'], + examples: [ + 'node scripts/pipeline/run.mjs release-verify-artifacts --artifacts-dir dist/release-assets/cli --public-key scripts/release/installers/happier-release.pub', + ], + }, + + 'release-compute-changed-components': { + summary: 'Compute “changed components” booleans from a git diff range (advanced helper).', + usage: 'node scripts/pipeline/run.mjs release-compute-changed-components --base <ref> --head <ref> [--out <githubOutputPath>]', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--dry-run Wrapper flag.', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag.', + '--keychain-account <name> Wrapper flag.', + '--base <ref> Script flag (required).', + '--head <ref> Script flag (required).', + '--out <path> Script flag; writes KEY=VALUE lines.', + ], + bullets: ['Used by workflows to decide what to publish.'], + examples: ['node scripts/pipeline/run.mjs release-compute-changed-components --base origin/main --head HEAD'], + }, + + 'release-resolve-bump-plan': { + summary: 'Resolve which components should be bumped given a preset + changed inputs (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-resolve-bump-plan --environment <preview|production> --bump-preset <none|patch|minor|major> [--github-output <path>]', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--dry-run Wrapper flag.', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag.', + '--keychain-account <name> Wrapper flag.', + '--environment <preview|production> Script flag (required).', + '--bump-preset <preset> Script flag (required).', + '--bump-app-override <preset> Script flag (default: preset).', + '--bump-cli-override <preset> Script flag (default: preset).', + '--bump-stack-override <preset> Script flag (default: preset).', + '--deploy-targets <csv> Script flag.', + '--changed-ui <bool>', + '--changed-cli <bool>', + '--changed-stack <bool>', + '--changed-server <bool>', + '--changed-website <bool>', + '--changed-shared <bool>', + '--github-output <path>', + ], + bullets: ['Generally invoked via release-bump-plan.'], + examples: ['node scripts/pipeline/run.mjs release-resolve-bump-plan --environment preview --bump-preset patch --changed-ui true'], + }, + + 'release-compute-deploy-plan': { + summary: 'Compute whether deploy branches need updating for each hosted component (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-compute-deploy-plan --deploy-environment <preview|production> --source-ref <ref> --force-deploy <bool> --deploy-ui <bool> --deploy-server <bool> --deploy-website <bool> --deploy-docs <bool>', + options: [ + '--deploy-environment <env> Script flag (required).', + '--source-ref <ref> Script flag (required).', + '--force-deploy <bool> Script flag.', + '--deploy-ui <bool> Script flag.', + '--deploy-server <bool> Script flag.', + '--deploy-website <bool> Script flag.', + '--deploy-docs <bool> Script flag.', + '--remote <name> Script flag (default: origin).', + '--github-output <path> Script flag.', + '--dry-run Wrapper flag.', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag.', + '--keychain-account <name> Wrapper flag.', + ], + bullets: ['Used internally to decide whether to promote deploy branches.'], + examples: [ + 'node scripts/pipeline/run.mjs release-compute-deploy-plan --deploy-environment preview --source-ref dev --force-deploy false --deploy-ui true --deploy-server true --deploy-website true --deploy-docs true', + ], + }, + + 'release-build-ui-web-bundle': { + summary: 'Build the UI web bundle artifact (advanced helper).', + usage: + 'node scripts/pipeline/run.mjs release-build-ui-web-bundle --channel <preview|stable> [--version <ver>] [--dist-dir <dir>] [--out-dir <dir>] [--skip-build]', + options: [ + '--deploy-environment <env> Wrapper flag (default: production).', + '--dry-run Wrapper flag.', + '--secrets-source <auto|env|keychain> Wrapper flag.', + '--keychain-service <name> Wrapper flag.', + '--keychain-account <name> Wrapper flag.', + '--channel <preview|stable> Script flag.', + '--version <ver> Script flag; defaults to apps/ui package.json.', + '--dist-dir <dir> Script flag (default: apps/ui/dist).', + '--out-dir <dir> Script flag (default: dist/release-assets/ui-web).', + '--skip-build Script flag.', + ], + bullets: ['Most operators should use publish-ui-web, not this helper.'], + examples: ['node scripts/pipeline/run.mjs release-build-ui-web-bundle --channel preview --skip-build'], + }, +}; + diff --git a/scripts/pipeline/cli/help-specs/tauri.mjs b/scripts/pipeline/cli/help-specs/tauri.mjs new file mode 100644 index 000000000..d8338a5ed --- /dev/null +++ b/scripts/pipeline/cli/help-specs/tauri.mjs @@ -0,0 +1,95 @@ +// @ts-check + +/** + * @typedef {{ + * summary: string; + * usage: string; + * options?: string[]; + * bullets: string[]; + * examples: string[]; + * }} CommandHelpSpec + */ + +/** @type {Record<string, CommandHelpSpec>} */ +export const COMMAND_HELP_TAURI = { + 'tauri-validate-updater-pubkey': { + summary: 'Validate that the Tauri updater public key matches the configured signing key.', + usage: 'node scripts/pipeline/run.mjs tauri-validate-updater-pubkey --config-path <path> [--dry-run]', + options: ['--config-path <path> Required.', '--dry-run'], + bullets: ['Run this when rotating signing keys or updating the updater config.'], + examples: [ + 'node scripts/pipeline/run.mjs tauri-validate-updater-pubkey --config-path apps/ui/src-tauri/tauri.conf.json', + ], + }, + + 'tauri-prepare-assets': { + summary: 'Prepare Tauri publish assets (merge UI web + updater artifacts into publish dir).', + usage: + 'node scripts/pipeline/run.mjs tauri-prepare-assets --environment <preview|production> --repo <owner/repo> --ui-version <semver> [--artifacts-dir <dir>] [--publish-dir <dir>]', + options: [ + '--environment <preview|production> Required.', + '--repo <owner/repo> Required.', + '--ui-version <semver> Required.', + '--artifacts-dir <dir> (default: dist/tauri/updates).', + '--publish-dir <dir> (default: dist/tauri/publish).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Used by desktop release workflows before publishing updater releases.'], + examples: ['node scripts/pipeline/run.mjs tauri-prepare-assets --environment preview --repo happier-dev/happier --ui-version 0.1.0'], + }, + + 'tauri-build-updater-artifacts': { + summary: 'Build Tauri updater artifacts (desktop binaries + signatures).', + usage: + 'node scripts/pipeline/run.mjs tauri-build-updater-artifacts --environment <preview|production> [--build-version <semver>] [--tauri-target <target>] [--ui-dir <dir>]', + options: [ + '--environment <preview|production> Required.', + '--build-version <semver> Optional.', + '--tauri-target <target> Optional; build a single target.', + '--ui-dir <dir> (default: apps/ui).', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Requires TAURI_SIGNING_PRIVATE_KEY (and Apple signing/notarization secrets for macOS).'], + examples: ['node scripts/pipeline/run.mjs tauri-build-updater-artifacts --environment production --ui-dir apps/ui'], + }, + + 'tauri-notarize-macos-artifacts': { + summary: 'Notarize macOS Tauri artifacts (post-build step).', + usage: 'node scripts/pipeline/run.mjs tauri-notarize-macos-artifacts [--ui-dir <dir>] [--tauri-target <target>] [--dry-run]', + options: [ + '--ui-dir <dir> (default: apps/ui).', + '--tauri-target <target> Optional.', + '--dry-run', + '--secrets-source <auto|env|keychain>', + '--keychain-service <name> (default: happier/pipeline).', + '--keychain-account <name>', + ], + bullets: ['Requires Apple notarization credentials (API key + team/issuer).'], + examples: ['node scripts/pipeline/run.mjs tauri-notarize-macos-artifacts --ui-dir apps/ui'], + }, + + 'tauri-collect-updater-artifacts': { + summary: 'Collect/normalize updater artifacts into a directory for publishing.', + usage: + 'node scripts/pipeline/run.mjs tauri-collect-updater-artifacts --environment <preview|production> --platform-key <key> --ui-version <semver> [--tauri-target <target>] [--ui-dir <dir>]', + options: [ + '--environment <preview|production> Required.', + '--platform-key <key> Required; e.g. darwin-arm64.', + '--ui-version <semver> Required.', + '--tauri-target <target> Optional.', + '--ui-dir <dir> (default: apps/ui).', + '--dry-run', + ], + bullets: ['Used for assembling multi-platform updater releases.'], + examples: [ + 'node scripts/pipeline/run.mjs tauri-collect-updater-artifacts --environment preview --platform-key darwin-arm64 --ui-version 0.1.0', + ], + }, +}; + diff --git a/scripts/pipeline/cli/help.mjs b/scripts/pipeline/cli/help.mjs new file mode 100644 index 000000000..386398e2a --- /dev/null +++ b/scripts/pipeline/cli/help.mjs @@ -0,0 +1,144 @@ +// @ts-check + +/** + * @typedef {{ + * enabled: boolean; + * bold: (s: string) => string; + * dim: (s: string) => string; + * cyan: (s: string) => string; + * green: (s: string) => string; + * yellow: (s: string) => string; + * red: (s: string) => string; + * }} AnsiStyle + */ + +/** + * @param {string[]} lines + * @param {number} spaces + */ +function indent(lines, spaces) { + const pad = ' '.repeat(Math.max(0, spaces)); + return lines.map((l) => (l ? `${pad}${l}` : l)); +} + +/** + * @param {string} s + */ +function code(s) { + return `\`${s}\``; +} + +import { COMMAND_HELP } from './help-specs.mjs'; + +/** + * @param {{ style: AnsiStyle; cliRelPath?: string }} opts + */ +export function renderPipelineHelp(opts) { + const style = opts.style; + const cli = opts.cliRelPath || 'scripts/pipeline/run.mjs'; + + const lines = [ + style.cyan(style.bold('Happier Pipeline')), + '', + style.bold('Usage:'), + ` node ${cli} ${style.bold('<command>')} [args...]`, + '', + style.bold('Global flags:'), + ...indent( + [ + `${style.bold('--help')} / ${style.bold('-h')} Show help`, + `${style.bold('--no-color')} Disable ANSI colors (also respects NO_COLOR=1)`, + `${style.bold('--color')} Force ANSI colors`, + ], + 2, + ), + '', + style.bold('Secrets:'), + ...indent( + [ + `Many commands accept ${style.bold('--secrets-source')} ${style.bold('<auto|env|keychain>')}.`, + `Keychain mode uses ${style.bold('--keychain-service')} / ${style.bold('--keychain-account')} (default service: happier/pipeline).`, + ], + 2, + ), + '', + style.bold('Help:'), + ` node ${cli} --help`, + ` node ${cli} help`, + ` node ${cli} help ${style.bold('<command>')}`, + ` node ${cli} ${style.bold('<command>')} --help`, + '', + style.bold('Common commands:'), + ...indent( + [ + `${style.bold('release')} ${COMMAND_HELP.release.summary}`, + `${style.bold('npm-release')} ${COMMAND_HELP['npm-release'].summary}`, + `${style.bold('deploy')} ${COMMAND_HELP.deploy.summary}`, + `${style.bold('promote-deploy-branch')} ${COMMAND_HELP['promote-deploy-branch'].summary}`, + `${style.bold('checks')} ${COMMAND_HELP.checks.summary}`, + `${style.bold('docker-publish')} ${COMMAND_HELP['docker-publish'].summary}`, + `${style.bold('ui-mobile-release')} ${COMMAND_HELP['ui-mobile-release'].summary}`, + `${style.bold('expo-submit')} ${COMMAND_HELP['expo-submit'].summary}`, + '', + style.dim(`Tip: prefer ${code(`node ${cli} <command> --dry-run`)} first.`), + ], + 2, + ), + '', + style.bold('All commands:'), + ...indent( + Object.keys(COMMAND_HELP) + .sort((a, b) => a.localeCompare(b)) + .map((name) => `${style.bold(name.padEnd(28))} ${COMMAND_HELP[name].summary}`), + 2, + ), + ]; + + return `${lines.join('\n')}\n`; +} + +/** + * @param {{ style: AnsiStyle; command: string; cliRelPath?: string }} opts + */ +export function renderCommandHelp(opts) { + const style = opts.style; + const cli = opts.cliRelPath || 'scripts/pipeline/run.mjs'; + const command = String(opts.command ?? '').trim(); + const spec = COMMAND_HELP[command] ?? null; + + if (!command) { + return renderPipelineHelp({ style, cliRelPath: cli }); + } + + if (!spec) { + const lines = [ + style.red(style.bold(`Unknown command: ${command}`)), + '', + 'Run:', + ` node ${cli} --help`, + ]; + return `${lines.join('\n')}\n`; + } + + const lines = [ + style.cyan(style.bold(`Happier Pipeline — ${command}`)), + '', + spec.summary, + '', + style.bold('Usage:'), + ` ${spec.usage.replace(/^node scripts\/pipeline\/run\.mjs/, `node ${cli}`)}`, + '', + ...(Array.isArray(spec.options) && spec.options.length > 0 + ? [style.bold('Options:'), ...indent(spec.options.map((o) => `- ${o}`), 2), ''] + : []), + ...(spec.bullets.length > 0 + ? [style.bold('Notes:'), ...indent(spec.bullets.map((b) => `- ${b}`), 2), ''] + : []), + ...(spec.examples.length > 0 + ? [style.bold('Examples:'), ...indent(spec.examples.map((e) => e), 2), ''] + : []), + style.dim(`More: node ${cli} --help`), + ]; + + return `${lines.join('\n')}\n`; +} diff --git a/scripts/pipeline/docker/publish-images.mjs b/scripts/pipeline/docker/publish-images.mjs index 2476f92c6..97ce02b5b 100644 --- a/scripts/pipeline/docker/publish-images.mjs +++ b/scripts/pipeline/docker/publish-images.mjs @@ -31,6 +31,19 @@ function parseBool(value, name) { fail(`${name} must be 'true' or 'false' (got: ${value})`); } +/** + * @param {string} raw + * @returns {string[]} + */ +function splitCsvLower(raw) { + const v = String(raw ?? '').trim(); + if (!v) return []; + return v + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} + /** * @param {string} cmd * @param {string[]} args @@ -220,7 +233,7 @@ function dockerLogin(opts) { const username = String(process.env.DOCKERHUB_USERNAME ?? '').trim(); const token = String(process.env.DOCKERHUB_TOKEN ?? '').trim(); if (opts.dryRun) { - const printable = `docker login --username ${username || '$DOCKERHUB_USERNAME'} --password-stdin`; + const printable = `docker login docker.io --username ${username || '$DOCKERHUB_USERNAME'} --password-stdin`; console.log(`[dry-run] ${printable}`); return; } @@ -252,6 +265,117 @@ function dockerLogin(opts) { } } +/** + * @returns {boolean} + */ +function isGithubActions() { + return String(process.env.GITHUB_ACTIONS ?? '') + .trim() + .toLowerCase() === 'true'; +} + +/** + * @param {string[]} args + * @returns {string} + */ +function tryGh(args) { + try { + const out = execFileSync('gh', args, { + env: process.env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }); + return String(out ?? '').trim(); + } catch { + return ''; + } +} + +/** + * @param {{ dryRun: boolean }} opts + */ +function dockerLoginGhcr(opts) { + const registry = String(process.env.GHCR_REGISTRY ?? 'ghcr.io').trim() || 'ghcr.io'; + const localMode = !isGithubActions(); + + let username = String(process.env.GHCR_USERNAME ?? process.env.GITHUB_ACTOR ?? '').trim(); + let token = String(process.env.GHCR_TOKEN ?? process.env.GITHUB_TOKEN ?? '').trim(); + + if (!opts.dryRun && localMode) { + if (!token) token = tryGh(['auth', 'token']); + if (!username) username = tryGh(['api', 'user', '-q', '.login']); + } + + if (opts.dryRun) { + const printable = `docker login ${registry} --username ${username || '$GHCR_USERNAME'} --password-stdin`; + console.log(`[dry-run] ${printable}`); + return; + } + + if (!username) { + fail( + [ + '[pipeline] missing GHCR_USERNAME (required to push GHCR images).', + 'Fix: set GHCR_USERNAME, or authenticate with GitHub CLI locally via `gh auth login`.', + ].join('\n'), + ); + } + if (!token) { + fail( + [ + '[pipeline] missing GHCR_TOKEN (required to push GHCR images).', + 'Fix: set GHCR_TOKEN, or authenticate with GitHub CLI locally via `gh auth login`.', + ].join('\n'), + ); + } + + console.log(`[pipeline] docker login: ${registry}`); + try { + execFileSync('docker', ['login', registry, '--username', username, '--password-stdin'], { + env: process.env, + input: `${token}\n`, + stdio: ['pipe', 'inherit', 'inherit'], + timeout: 60_000, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + fail( + [ + `[pipeline] docker login failed for ${registry}.`, + 'Fix: verify GHCR_USERNAME/GHCR_TOKEN (token needs packages:write on the repo or org).', + `Error: ${msg}`, + ].join('\n'), + ); + } +} + +/** + * @param {unknown} raw + * @returns {Set<'dockerhub' | 'ghcr'>} + */ +function resolveRegistries(raw) { + const fromEnv = String(process.env.PIPELINE_DOCKER_REGISTRIES ?? '').trim(); + const v = String(raw ?? '').trim() || fromEnv || 'dockerhub'; + const tokens = splitCsvLower(v); + if (tokens.length === 0) return new Set(['dockerhub']); + + /** @type {Set<'dockerhub' | 'ghcr'>} */ + const out = new Set(); + for (const t of tokens) { + if (t === 'dockerhub') { + out.add('dockerhub'); + continue; + } + if (t === 'ghcr') { + out.add('ghcr'); + continue; + } + fail(`Unsupported docker registry token: ${t} (supported: dockerhub,ghcr)`); + } + return out; +} + /** * @param {string} builderName * @param {{ dryRun: boolean }} opts @@ -326,11 +450,12 @@ async function main() { const { values } = parseArgs({ options: { channel: { type: 'string' }, + registries: { type: 'string', default: '' }, 'source-ref': { type: 'string', default: '' }, sha: { type: 'string', default: '' }, 'push-latest': { type: 'string', default: 'true' }, 'build-relay': { type: 'string', default: 'true' }, - 'build-devcontainer': { type: 'string', default: 'true' }, + 'build-dev-box': { type: 'string', default: 'true' }, 'dry-run': { type: 'boolean', default: false }, }, allowPositionals: false, @@ -339,32 +464,47 @@ async function main() { const channel = String(values.channel ?? '').trim(); if (!channel) fail('--channel is required'); + const registries = resolveRegistries(values.registries); const { channelTag, floatTag, policyEnv } = resolveTagSpec(channel); const pushLatest = parseBool(values['push-latest'], '--push-latest'); const buildRelay = parseBool(values['build-relay'], '--build-relay'); - const buildDevcontainer = parseBool(values['build-devcontainer'], '--build-devcontainer'); + const buildDevBox = parseBool(values['build-dev-box'], '--build-dev-box'); const dryRun = values['dry-run'] === true; const shaRaw = String(values.sha ?? '').trim(); const sha = shaRaw || run('git', ['rev-parse', 'HEAD'], { dryRun: false, stdio: 'pipe' }).trim(); const shortSha = sha.slice(0, 12); - const relayBase = 'happierdev/relay-server'; - const devBase = 'happierdev/dev-container'; + const ghcrNamespaceRaw = String(process.env.GHCR_NAMESPACE ?? 'ghcr.io/happier-dev').trim(); + const ghcrNamespace = ghcrNamespaceRaw.endsWith('/') ? ghcrNamespaceRaw.slice(0, -1) : ghcrNamespaceRaw; + + /** @type {string[]} */ + const relayBases = []; + /** @type {string[]} */ + const devBases = []; + if (registries.has('dockerhub')) { + relayBases.push('happierdev/relay-server'); + devBases.push('happierdev/dev-box'); + } + if (registries.has('ghcr')) { + relayBases.push(`${ghcrNamespace}/relay-server`); + devBases.push(`${ghcrNamespace}/dev-box`); + } dockerPreflight({ dryRun }); - dockerLogin({ dryRun }); + if (registries.has('dockerhub')) dockerLogin({ dryRun }); + if (registries.has('ghcr')) dockerLoginGhcr({ dryRun }); const builder = ensureMultiarchBuilder({ dryRun }); /** @type {string[]} */ - const relayTags = [`${relayBase}:${channelTag}`, `${relayBase}:${channelTag}-${shortSha}`]; + const relayTags = relayBases.flatMap((base) => [`${base}:${channelTag}`, `${base}:${channelTag}-${shortSha}`]); /** @type {string[]} */ - const devTags = [`${devBase}:${channelTag}`, `${devBase}:${channelTag}-${shortSha}`]; + const devTags = devBases.flatMap((base) => [`${base}:${channelTag}`, `${base}:${channelTag}-${shortSha}`]); if (pushLatest) { - relayTags.push(`${relayBase}:${floatTag}`); - devTags.push(`${devBase}:${floatTag}`); + for (const base of relayBases) relayTags.push(`${base}:${floatTag}`); + for (const base of devBases) devTags.push(`${base}:${floatTag}`); } const useGhaCache = String(process.env.GITHUB_ACTIONS ?? '').toLowerCase() === 'true'; @@ -405,19 +545,19 @@ async function main() { }); } - if (buildDevcontainer) { + if (buildDevBox) { const args = [ 'buildx', 'build', '--file', - 'docker/devcontainer/Dockerfile', + 'docker/dev-box/Dockerfile', '--builder', builder, '--platform', 'linux/amd64,linux/arm64', '--push', - ...(useGhaCache ? ['--cache-from', 'type=gha,scope=devcontainer'] : []), - ...(useGhaCache ? ['--cache-to', 'type=gha,mode=max,scope=devcontainer'] : []), + ...(useGhaCache ? ['--cache-from', 'type=gha,scope=dev-box'] : []), + ...(useGhaCache ? ['--cache-to', 'type=gha,mode=max,scope=dev-box'] : []), '--label', `org.opencontainers.image.revision=${sha}`, ...devTags.flatMap((t) => ['--tag', t]), diff --git a/scripts/pipeline/expo/eas-local-build-env.mjs b/scripts/pipeline/expo/eas-local-build-env.mjs new file mode 100644 index 000000000..7a2104df9 --- /dev/null +++ b/scripts/pipeline/expo/eas-local-build-env.mjs @@ -0,0 +1,42 @@ +// @ts-check + +/** + * EAS local builds run an `expo doctor` phase via `@expo/build-tools` which can fail + * for minor dependency drift and block local iteration. + * + * We disable that step by default for the pipeline’s local build mode, while still + * allowing operators/CI to opt back in by explicitly setting the env var. + * + * @param {{ baseEnv: Record<string, string>; platform: 'ios' | 'android' }} opts + * @returns {Record<string, string>} + */ +export function createEasLocalBuildEnv(opts) { + const env = { ...opts.baseEnv }; + if (!Object.prototype.hasOwnProperty.call(env, 'EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP')) { + env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP = '1'; + } + if (opts.platform === 'ios') { + if (!Object.prototype.hasOwnProperty.call(env, 'FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT')) { + env.FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT = '30'; + } + + // Xcode’s export pipeline can invoke `/usr/bin/rsync` (openrsync) which internally spawns a + // server-side `rsync` process via PATH. If Homebrew rsync appears before `/usr/bin`, the two + // implementations can mismatch and fail with: + // "rsync: on remote machine: --extended-attributes: unknown option" + // Ensure `/usr/bin` precedes `/opt/homebrew/bin` so openrsync finds itself for the server side. + const pathRaw = String(env.PATH ?? ''); + if (pathRaw) { + const parts = pathRaw.split(':').filter(Boolean); + const idxUsr = parts.indexOf('/usr/bin'); + const idxBrew = parts.indexOf('/opt/homebrew/bin'); + if (idxUsr !== -1 && idxBrew !== -1 && idxBrew < idxUsr) { + parts.splice(idxUsr, 1); + const insertAt = parts.indexOf('/opt/homebrew/bin'); + parts.splice(insertAt === -1 ? 0 : insertAt, 0, '/usr/bin'); + env.PATH = parts.join(':'); + } + } + } + return env; +} diff --git a/scripts/pipeline/expo/native-build.mjs b/scripts/pipeline/expo/native-build.mjs index f0d80f5c0..cb8969d8c 100644 --- a/scripts/pipeline/expo/native-build.mjs +++ b/scripts/pipeline/expo/native-build.mjs @@ -8,6 +8,7 @@ import { parseArgs } from 'node:util'; import { stageRepoForDagger } from './stage-repo-for-dagger.mjs'; import { rewriteEasLocalBuildArtifactPath } from './rewrite-eas-local-build-artifact-path.mjs'; import { assertDockerCanRunLinuxAmd64 } from '../docker/assert-docker-can-run-linux-amd64.mjs'; +import { createEasLocalBuildEnv } from './eas-local-build-env.mjs'; function fail(message) { console.error(message); @@ -193,8 +194,9 @@ async function main() { } const localRuntime = /** @type {'host' | 'dagger'} */ (localRuntimeRaw); + const isCi = String(process.env.CI ?? '').trim().toLowerCase() === 'true' || String(process.env.GITHUB_ACTIONS ?? '').trim() === 'true'; const expoToken = String(process.env.EXPO_TOKEN ?? '').trim(); - if (!expoToken) { + if ((buildMode === 'cloud' || localRuntime === 'dagger' || isCi) && !expoToken) { fail('EXPO_TOKEN is required for Expo native builds.'); } @@ -309,7 +311,8 @@ async function main() { if (!dryRun) fs.mkdirSync(path.dirname(absOut), { recursive: true }); const baseEnv = /** @type {Record<string, string>} */ ({ ...process.env }); - const buildEnv = withUtf8LocaleDefaults(baseEnv); + const buildEnvBase = withUtf8LocaleDefaults(baseEnv); + const buildEnv = createEasLocalBuildEnv({ baseEnv: buildEnvBase, platform }); if (platform === 'ios') { // CocoaPods on macOS can crash when locale is `C`/`C.UTF-8` even if the terminal locale is set. // Force a known UTF-8 locale for the local build subprocess tree. @@ -348,27 +351,33 @@ async function main() { const effectiveRepoDir = dryRun ? repoRoot : staged?.stagedRepoDir ?? repoRoot; const effectiveUiDir = path.join(effectiveRepoDir, 'apps', 'ui'); + const pipelineInteractive = + String(process.env.PIPELINE_INTERACTIVE ?? '').trim() === '1' || + String(process.env.PIPELINE_INTERACTIVE ?? '').trim().toLowerCase() === 'true'; + const localNonInteractive = isCi || !pipelineInteractive; + try { if (staged && effectiveRepoDir !== repoRoot) { maybeLinkNodeModulesIntoStage({ repoRoot, stagedRepoDir: effectiveRepoDir, dryRun }); } ensureStagedGitRepo({ repoDir: effectiveRepoDir, env: buildEnv, dryRun }); + const localArgs = [ + '--yes', + `eas-cli@${easCliVersion}`, + 'build', + '--platform', + platform, + '--profile', + profile, + '--local', + '--output', + absOut, + ...(localNonInteractive ? ['--non-interactive'] : []), + ]; run( opts, 'npx', - [ - '--yes', - `eas-cli@${easCliVersion}`, - 'build', - '--platform', - platform, - '--profile', - profile, - '--local', - '--output', - absOut, - '--non-interactive', - ], + localArgs, { cwd: effectiveUiDir, env: buildEnv, stdio: 'inherit' }, ); } finally { diff --git a/scripts/pipeline/expo/submit.mjs b/scripts/pipeline/expo/submit.mjs index 5fcf63983..679a3e634 100644 --- a/scripts/pipeline/expo/submit.mjs +++ b/scripts/pipeline/expo/submit.mjs @@ -2,6 +2,7 @@ import path from 'node:path'; import fs from 'node:fs'; +import os from 'node:os'; import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; @@ -12,6 +13,151 @@ function fail(message) { process.exit(1); } +/** + * @param {string} cmd + * @param {Record<string, string>} env + * @returns {boolean} + */ +function commandExists(cmd, env) { + try { + execFileSync('bash', ['-lc', `command -v ${JSON.stringify(cmd)} >/dev/null 2>&1`], { + env, + stdio: 'ignore', + timeout: 10_000, + }); + return true; + } catch { + return false; + } +} + +/** + * @param {string} key + * @param {string} xml + * @returns {string} + */ +function readPlistXmlStringValue(key, xml) { + const re = new RegExp(`<key>${key}<\\/key>\\s*<string>([^<]*)<\\/string>`, 'm'); + const m = xml.match(re); + return m ? String(m[1] ?? '').trim() : ''; +} + +/** + * @param {string} zipPath + * @param {Record<string, string>} env + * @returns {string[]} + */ +function listZipEntries(zipPath, env) { + const out = execFileSync('unzip', ['-Z1', zipPath], { + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + return String(out ?? '') + .split('\n') + .map((v) => v.trim()) + .filter(Boolean); +} + +/** + * @param {string} zipPath + * @param {string} entry + * @param {Record<string, string>} env + * @returns {Buffer} + */ +function extractZipEntry(zipPath, entry, env) { + const out = execFileSync('unzip', ['-p', zipPath, entry], { + env, + encoding: null, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + return Buffer.isBuffer(out) ? out : Buffer.from(out ?? ''); +} + +/** + * Resolves the expected iOS bundle identifier for the requested environment. + * + * We intentionally avoid executing `apps/ui/app.config.js` here because it mixes ESM exports with `require(...)` + * calls that are evaluated by Expo tooling, not by plain Node. Instead, we treat it as a configuration source and + * extract the stable bundle ids from the file. + * + * @param {{ repoRoot: string; environment: 'preview' | 'production'; env: Record<string, string> }} opts + * @returns {{ bundleIdentifier: string; source: string }} + */ +function resolveExpectedIosBundleId(opts) { + const override = String(opts.env.EXPO_APP_BUNDLE_ID ?? opts.env.HAPPY_STACKS_IOS_BUNDLE_ID ?? '').trim(); + if (override) return { bundleIdentifier: override, source: 'env override' }; + + const configPath = path.join(opts.repoRoot, 'apps', 'ui', 'app.config.js'); + if (!fs.existsSync(configPath)) return { bundleIdentifier: '', source: 'missing config' }; + const raw = fs.readFileSync(configPath, 'utf8'); + + const prodMatch = raw.match(/iosBundleId:\s*"([^"]+)"/); + const prod = String(prodMatch?.[1] ?? '').trim(); + + const previewMatch = raw.match(/bundleIdsByVariant\s*=\s*\{[\s\S]*?\bpreview:\s*"([^"]+)"/m); + const preview = String(previewMatch?.[1] ?? '').trim(); + + const bundleIdentifier = opts.environment === 'production' ? prod : preview; + return { bundleIdentifier, source: 'apps/ui/app.config.js' }; +} + +/** + * @param {{ ipaPath: string; env: Record<string, string> }} opts + * @returns {{ bundleIdentifier: string; displayName: string; buildNumber: string; version: string } | null} + */ +function readIosIpaMetadata(opts) { + if (!opts.ipaPath.endsWith('.ipa')) return null; + if (!fs.existsSync(opts.ipaPath)) return null; + if (!commandExists('unzip', opts.env)) return null; + + const entries = listZipEntries(opts.ipaPath, opts.env); + const infoEntry = entries.find((e) => /^Payload\/.+\.app\/Info\.plist$/.test(e)); + if (!infoEntry) return null; + + const plistBuf = extractZipEntry(opts.ipaPath, infoEntry, opts.env); + if (!plistBuf || plistBuf.length === 0) return null; + + // Prefer plutil (handles binary plists from real IPAs). Fall back to XML parsing for simple test artifacts. + if (commandExists('plutil', opts.env)) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'happier-ipa-info-')); + const plistPath = path.join(dir, 'Info.plist'); + fs.writeFileSync(plistPath, plistBuf); + + const readKey = (key) => { + try { + return execFileSync('plutil', ['-extract', key, 'raw', '-o', '-', plistPath], { + env: opts.env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }).trim(); + } catch { + return ''; + } + }; + + return { + bundleIdentifier: readKey('CFBundleIdentifier'), + displayName: readKey('CFBundleDisplayName') || readKey('CFBundleName'), + version: readKey('CFBundleShortVersionString'), + buildNumber: readKey('CFBundleVersion'), + }; + } + + const asText = plistBuf.toString('utf8'); + if (!asText.includes('<plist') || !asText.includes('CFBundleIdentifier')) return null; + return { + bundleIdentifier: readPlistXmlStringValue('CFBundleIdentifier', asText), + displayName: + readPlistXmlStringValue('CFBundleDisplayName', asText) || readPlistXmlStringValue('CFBundleName', asText), + version: readPlistXmlStringValue('CFBundleShortVersionString', asText), + buildNumber: readPlistXmlStringValue('CFBundleVersion', asText), + }; +} + /** * @param {{ dryRun: boolean }} opts * @param {string} cmd @@ -141,10 +287,17 @@ function main() { const dryRun = values['dry-run'] === true; const opts = { dryRun }; + const isCi = String(process.env.CI ?? '').trim().toLowerCase() === 'true' || String(process.env.GITHUB_ACTIONS ?? '').trim() === 'true'; const expoToken = String(process.env.EXPO_TOKEN ?? '').trim(); - if (!expoToken) { + if (isCi && !expoToken) { fail('EXPO_TOKEN is required for Expo submit.'); } + const pipelineInteractive = + String(process.env.PIPELINE_INTERACTIVE ?? '').trim() === '1' || + String(process.env.PIPELINE_INTERACTIVE ?? '').trim().toLowerCase() === 'true'; + // Default to non-interactive when EXPO_TOKEN is present (matches CI behavior), but allow an explicit + // local escape hatch for one-time credential bootstrap (e.g. Google Play service account setup). + const nonInteractive = isCi || (Boolean(expoToken) && !pipelineInteractive); const easCliVersion = String(values['eas-cli-version'] ?? '').trim() || String(process.env.EAS_CLI_VERSION ?? '').trim() || '18.0.1'; @@ -154,20 +307,55 @@ function main() { const uiDir = path.join(repoRoot, 'apps', 'ui'); const submitPathAbs = submitPathRaw ? path.resolve(repoRoot, submitPathRaw) : ''; - if (submitPathAbs && !dryRun) { - // Avoid importing fs for this script; let EAS fail with a clear message if the path is invalid. + if (submitPathAbs) { + if (!fs.existsSync(submitPathAbs)) { + fail( + [ + `${submitPathAbs} doesn't exist`, + '', + 'Tip: local production builds are versioned.', + 'Example iOS: dist/ui-mobile/happier-production-ios-v<uiVersion>.ipa', + 'Example Android (AAB): dist/ui-mobile/happier-production-android-v<uiVersion>.aab', + '', + 'Run: ls dist/ui-mobile', + ].join('\n'), + ); + } + + if (platforms.includes('ios')) { + const meta = readIosIpaMetadata({ ipaPath: submitPathAbs, env: process.env }); + if (meta?.bundleIdentifier) { + const expected = resolveExpectedIosBundleId({ repoRoot, environment, env: process.env }); + if (expected.bundleIdentifier && meta.bundleIdentifier !== expected.bundleIdentifier) { + fail( + [ + `iOS archive bundle identifier mismatch for environment='${environment}'.`, + '', + `Expected (${expected.source}): ${expected.bundleIdentifier}`, + `Actual (archive): ${meta.bundleIdentifier}${meta.displayName ? ` (${meta.displayName})` : ''}`, + meta.version || meta.buildNumber + ? `Archive version/build: ${meta.version || '?'} (${meta.buildNumber || '?'})` + : '', + '', + 'Fix: rebuild the iOS archive with the correct EAS build profile for the requested environment, then re-run expo-submit.', + ] + .filter(Boolean) + .join('\n'), + ); + } + } + } } - if (platforms.includes('ios')) { + if (platforms.includes('ios') && nonInteractive) { ensureIosSubmitAscApiKeyFile({ repoRoot, uiDir, submitProfile, dryRun }); } let hadFailure = false; for (const platform of platforms) { - const baseArgs = ['--yes', `eas-cli@${easCliVersion}`, 'submit', '--platform', platform]; - const submitArgs = submitPathAbs - ? [...baseArgs, '--path', submitPathAbs, '--profile', submitProfile, '--non-interactive'] - : [...baseArgs, '--latest', '--non-interactive']; + const baseArgs = ['--yes', `eas-cli@${easCliVersion}`, 'submit', '--platform', platform, '--profile', submitProfile]; + const submitArgs = submitPathAbs ? [...baseArgs, '--path', submitPathAbs] : [...baseArgs, '--latest']; + if (nonInteractive) submitArgs.push('--non-interactive'); const appEnv = String(process.env.APP_ENV ?? '').trim() || environment; const result = run(opts, 'npx', submitArgs, { diff --git a/scripts/pipeline/git/ensure-clean-worktree.mjs b/scripts/pipeline/git/ensure-clean-worktree.mjs new file mode 100644 index 000000000..0d9151f56 --- /dev/null +++ b/scripts/pipeline/git/ensure-clean-worktree.mjs @@ -0,0 +1,75 @@ +// @ts-check + +import { execFileSync } from 'node:child_process'; + +/** + * @param {string} cwd + * @returns {boolean} + */ +function isGitRepo(cwd) { + try { + const out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + }) + .trim() + .toLowerCase(); + return out === 'true'; + } catch { + return false; + } +} + +/** + * @param {string} cwd + * @returns {string[]} + */ +function gitStatusPorcelain(cwd) { + const out = execFileSync('git', ['status', '--porcelain=v1'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }); + return String(out || '') + .replaceAll('\r', '') + .split('\n') + .map((l) => l.trimEnd()) + .filter(Boolean); +} + +/** + * Require a clean git worktree unless explicitly overridden. + * This is intended to prevent accidental publishes from a dirty local checkout. + * + * @param {{ + * cwd: string; + * allowDirty: boolean; + * maxLines?: number; + * }} opts + */ +export function assertCleanWorktree(opts) { + if (opts.allowDirty) return; + if (!isGitRepo(opts.cwd)) return; + + const lines = gitStatusPorcelain(opts.cwd); + if (lines.length === 0) return; + + const maxLines = typeof opts.maxLines === 'number' && Number.isFinite(opts.maxLines) ? opts.maxLines : 20; + const snippet = lines.slice(0, Math.max(0, maxLines)); + const extra = lines.length > snippet.length ? `\n… and ${lines.length - snippet.length} more` : ''; + throw new Error( + [ + 'git worktree is dirty; refusing to publish from a non-reproducible state.', + 'Commit/stash changes, or re-run with --allow-dirty to override.', + '', + ...snippet, + extra, + ] + .filter(Boolean) + .join('\n'), + ); +} + diff --git a/scripts/pipeline/github/audit-release-assets.mjs b/scripts/pipeline/github/audit-release-assets.mjs new file mode 100644 index 000000000..c48918dc5 --- /dev/null +++ b/scripts/pipeline/github/audit-release-assets.mjs @@ -0,0 +1,183 @@ +// @ts-check + +import { execFileSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; + +import { CLI_STACK_TARGETS, SERVER_TARGETS, resolveTargets } from '../release/lib/binary-release.mjs'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {string} raw + * @returns {string[]} + */ +function parseCsv(raw) { + const v = String(raw ?? '').trim(); + if (!v) return []; + return v + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * @param {unknown} value + * @param {string} name + * @returns {string[]} + */ +function parseAssetsJson(value, name) { + const raw = String(value ?? '').trim(); + if (!raw) return []; + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + fail(`${name} must be valid JSON (got invalid JSON)`); + } + if (!Array.isArray(parsed) || !parsed.every((x) => typeof x === 'string')) { + fail(`${name} must be a JSON array of strings`); + } + return parsed.map((s) => s.trim()).filter(Boolean); +} + +/** + * @param {'cli' | 'stack' | 'server'} kind + */ +function productForKind(kind) { + if (kind === 'cli') return 'happier'; + if (kind === 'stack') return 'hstack'; + return 'happier-server'; +} + +/** + * @param {'cli' | 'stack' | 'server'} kind + */ +function targetsForKind(kind) { + if (kind === 'server') return SERVER_TARGETS; + return CLI_STACK_TARGETS; +} + +/** + * @param {string[]} assets + * @param {string} product + * @returns {string} + */ +function inferVersionFromAssets(assets, product) { + const pattern = new RegExp(`^${product}-v(.+)-[a-z]+-(x64|arm64)\\.tar\\.gz$`); + for (const name of assets) { + const m = pattern.exec(String(name ?? '').trim()); + if (m?.[1]) return m[1]; + } + return ''; +} + +/** + * @param {{ tag: string; repo: string }} opts + * @returns {string[]} + */ +function fetchReleaseAssets({ tag, repo }) { + const out = execFileSync( + 'gh', + ['release', 'view', tag, '--repo', repo, '--json', 'assets', '--jq', '.assets[].name'], + { + env: process.env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + return String(out ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function main() { + const { values } = parseArgs({ + options: { + tag: { type: 'string' }, + kind: { type: 'string' }, + version: { type: 'string', default: '' }, + targets: { type: 'string', default: '' }, + repo: { type: 'string', default: '' }, + 'assets-json': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); + + const tag = String(values.tag ?? '').trim(); + const kindRaw = String(values.kind ?? '').trim(); + const kind = kindRaw === 'cli' || kindRaw === 'stack' || kindRaw === 'server' ? kindRaw : ''; + if (!tag) fail('--tag is required'); + if (!kind) fail(`--kind must be 'cli', 'stack', or 'server' (got: ${kindRaw || '<empty>'})`); + + const repo = String(values.repo ?? '').trim() + || String(process.env.GH_REPO ?? '').trim() + || String(process.env.GITHUB_REPOSITORY ?? '').trim(); + const assetsJson = String(values['assets-json'] ?? '').trim(); + + /** @type {string[]} */ + let assets = []; + if (assetsJson) { + assets = parseAssetsJson(values['assets-json'], '--assets-json'); + } else { + if (!repo) { + fail('--repo is required when --assets-json is not provided (or set GH_REPO/GITHUB_REPOSITORY)'); + } + assets = fetchReleaseAssets({ tag, repo }); + } + + const product = productForKind(kind); + const availableTargets = targetsForKind(kind); + const requestedTargets = String(values.targets ?? '').trim(); + const targets = resolveTargets({ + availableTargets, + requested: requestedTargets || undefined, + }); + + const version = String(values.version ?? '').trim() || inferVersionFromAssets(assets, product); + if (!version) { + fail(`Unable to infer version for '${product}' from assets. Pass --version explicitly.`); + } + + const expected = new Set(); + for (const t of targets) { + expected.add(`${product}-v${version}-${t.os}-${t.arch}.tar.gz`); + } + expected.add(`checksums-${product}-v${version}.txt`); + expected.add(`checksums-${product}-v${version}.txt.minisig`); + + const found = new Set(assets); + const missing = [...expected].filter((name) => !found.has(name)); + const extra = assets.filter((name) => !expected.has(name)); + + const header = [ + `[pipeline] audit release assets: tag=${tag} kind=${kind}`, + ...(repo ? [`[pipeline] repo: ${repo}`] : []), + `[pipeline] version: ${version}`, + `[pipeline] expected: ${expected.size} found: ${assets.length}`, + ].join('\n'); + + if (missing.length === 0) { + console.log(`${header}\n[pipeline] OK`); + return; + } + + const lines = [ + header, + '', + '[pipeline] MISSING assets:', + ...missing.map((name) => `- ${name}`), + ]; + if (extra.length > 0) { + lines.push('', '[pipeline] EXTRA assets (ignored):', ...extra.map((name) => `- ${name}`)); + } + console.error(lines.join('\n')); + process.exit(1); +} + +main(); + diff --git a/scripts/pipeline/github/commit-and-push.mjs b/scripts/pipeline/github/commit-and-push.mjs new file mode 100644 index 000000000..4dbd6c5c6 --- /dev/null +++ b/scripts/pipeline/github/commit-and-push.mjs @@ -0,0 +1,189 @@ +// @ts-check + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {unknown} value + * @param {string} name + */ +function parseBool(value, name) { + const raw = String(value ?? '').trim().toLowerCase(); + if (raw === 'true') return true; + if (raw === 'false') return false; + fail(`${name} must be 'true' or 'false' (got: ${value})`); +} + +/** + * @param {string} raw + * @returns {string[]} + */ +function splitCsv(raw) { + const v = String(raw ?? '').trim(); + if (!v) return []; + return v + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cmd + * @param {string[]} args + * @param {{ cwd: string; env?: Record<string, string>; stdio?: import('node:child_process').StdioOptions }} extra + * @returns {string} + */ +function run(opts, cmd, args, extra) { + const printable = `${cmd} ${args.map((a) => (a.includes(' ') ? JSON.stringify(a) : a)).join(' ')}`; + if (opts.dryRun) { + console.log(`[dry-run] (cwd: ${extra.cwd}) ${printable}`); + return ''; + } + + return execFileSync(cmd, args, { + cwd: extra.cwd, + env: { ...process.env, ...(extra.env ?? {}) }, + encoding: 'utf8', + stdio: extra.stdio ?? 'inherit', + timeout: 10 * 60_000, + }); +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cwd + * @param {string} key + * @returns {string} + */ +function gitConfigGet(opts, cwd, key) { + if (opts.dryRun) return ''; + try { + return execFileSync('git', ['config', '--get', key], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return ''; + } +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cwd + * @returns {boolean} + */ +function hasStagedChanges(opts, cwd) { + if (opts.dryRun) return true; + const out = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + return Boolean(out); +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cwd + * @param {string} remote + * @param {string} branch + * @returns {boolean} + */ +function remoteBranchExists(opts, cwd, remote, branch) { + // `git ls-remote` is fast and doesn't require that the remote branch is already fetched. + if (opts.dryRun) { + run(opts, 'git', ['ls-remote', '--exit-code', '--heads', remote, branch], { cwd }); + return true; + } + try { + execFileSync('git', ['ls-remote', '--exit-code', '--heads', remote, branch], { + cwd, + stdio: ['ignore', 'ignore', 'ignore'], + timeout: 60_000, + }); + return true; + } catch { + return false; + } +} + +function main() { + const repoRoot = path.resolve(process.cwd()); + const { values } = parseArgs({ + options: { + paths: { type: 'string', default: '' }, + 'allow-missing': { type: 'string', default: 'false' }, + message: { type: 'string', default: '' }, + 'author-name': { type: 'string', default: 'github-actions[bot]' }, + 'author-email': { type: 'string', default: 'github-actions[bot]@users.noreply.github.com' }, + remote: { type: 'string', default: 'origin' }, + 'push-ref': { type: 'string', default: '' }, + 'push-mode': { type: 'string', default: 'auto' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const pathsRaw = String(values.paths ?? '').trim(); + const pathsList = splitCsv(pathsRaw); + if (pathsList.length < 1) fail('--paths is required (comma-separated)'); + + const allowMissing = parseBool(values['allow-missing'], '--allow-missing'); + const message = String(values.message ?? '').trim(); + if (!message) fail('--message is required'); + + const authorName = String(values['author-name'] ?? '').trim() || 'github-actions[bot]'; + const authorEmail = String(values['author-email'] ?? '').trim() || 'github-actions[bot]@users.noreply.github.com'; + const remote = String(values.remote ?? '').trim() || 'origin'; + + const pushRef = String(values['push-ref'] ?? '').trim(); + const pushModeRaw = String(values['push-mode'] ?? '').trim(); + const pushMode = pushModeRaw === 'auto' || pushModeRaw === 'always' || pushModeRaw === 'never' ? pushModeRaw : 'auto'; + if (pushModeRaw && pushMode !== pushModeRaw) { + fail(`--push-mode must be 'auto', 'always', or 'never' (got: ${pushModeRaw})`); + } + + const dryRun = values['dry-run'] === true; + const opts = { dryRun }; + + const cwd = repoRoot; + + const existingName = gitConfigGet(opts, cwd, 'user.name'); + const existingEmail = gitConfigGet(opts, cwd, 'user.email'); + if (!existingName) run(opts, 'git', ['config', 'user.name', authorName], { cwd }); + if (!existingEmail) run(opts, 'git', ['config', 'user.email', authorEmail], { cwd }); + + for (const relPath of pathsList) { + const abs = path.resolve(repoRoot, relPath); + const exists = fs.existsSync(abs); + if (!exists) { + if (allowMissing) continue; + fail(`Missing path: ${relPath}`); + } + run(opts, 'git', ['add', relPath], { cwd }); + } + + if (!hasStagedChanges(opts, cwd)) { + console.log('SKIP'); + console.log('DID_COMMIT=false'); + return; + } + + run(opts, 'git', ['commit', '-m', message], { cwd }); + console.log('DID_COMMIT=true'); + + if (pushMode === 'never') return; + if (!pushRef) fail('--push-ref is required when --push-mode is not never'); + + if (pushMode === 'auto') { + if (!remoteBranchExists(opts, cwd, remote, pushRef)) { + console.log(`Skipping push: '${pushRef}' is not a known branch ref.`); + return; + } + } + + run(opts, 'git', ['push', remote, `HEAD:refs/heads/${pushRef}`], { cwd }); +} + +main(); diff --git a/scripts/pipeline/github/lib/gh-release-commands.mjs b/scripts/pipeline/github/lib/gh-release-commands.mjs new file mode 100644 index 000000000..bb7113907 --- /dev/null +++ b/scripts/pipeline/github/lib/gh-release-commands.mjs @@ -0,0 +1,19 @@ +// @ts-check + +/** + * @param {{ tag: string; title: string; notes: string; targetSha: string }} input + * @returns {string[]} + */ +export function buildRollingReleaseEditArgs(input) { + const tag = String(input.tag ?? '').trim(); + const title = String(input.title ?? '').trim(); + const notes = String(input.notes ?? ''); + const targetSha = String(input.targetSha ?? '').trim(); + + if (!tag) throw new Error('tag is required'); + if (!title) throw new Error('title is required'); + if (!targetSha) throw new Error('targetSha is required'); + + return ['release', 'edit', tag, '--title', title, '--notes', notes, '--target', targetSha]; +} + diff --git a/scripts/pipeline/github/promote-branch.mjs b/scripts/pipeline/github/promote-branch.mjs index 697c7b81f..d621ab9ef 100644 --- a/scripts/pipeline/github/promote-branch.mjs +++ b/scripts/pipeline/github/promote-branch.mjs @@ -81,6 +81,41 @@ function run(cmd, args, opts) { }); } +/** + * @param {unknown} err + * @returns {{ stdout: string; stderr: string; message: string }} + */ +function normalizeExecError(err) { + const anyErr = /** @type {{ stdout?: unknown; stderr?: unknown; message?: unknown }} */ (err ?? {}); + return { + stdout: typeof anyErr?.stdout === 'string' ? anyErr.stdout : '', + stderr: typeof anyErr?.stderr === 'string' ? anyErr.stderr : '', + message: typeof anyErr?.message === 'string' ? anyErr.message : String(err ?? ''), + }; +} + +/** + * @param {unknown} err + * @returns {boolean} + */ +function isGhNotFoundError(err) { + const { stderr, message } = normalizeExecError(err); + return stderr.includes('(HTTP 404)') || message.includes('(HTTP 404)'); +} + +/** + * @param {unknown} err + * @returns {string} + */ +function formatExecError(err) { + const { stdout, stderr, message } = normalizeExecError(err); + const parts = []; + if (message) parts.push(message.trim()); + if (stderr) parts.push(stderr.trim()); + if (stdout) parts.push(stdout.trim()); + return parts.filter(Boolean).join('\n'); +} + /** * @param {string[]} files */ @@ -225,13 +260,18 @@ function main() { const force = mode === 'reset'; try { - run('gh', ['api', '-X', 'PATCH', updateApi, '-f', `sha=${sourceSha}`, '-f', `force=${force}`], { env: ghEnv }); - } catch { + run('gh', ['api', '-X', 'PATCH', updateApi, '-F', `sha=${sourceSha}`, '-F', `force=${force}`], { env: ghEnv }); + } catch (err) { + if (!isGhNotFoundError(err)) { + fail(`Failed to update refs/heads/${target}.\n${formatExecError(err)}`); + } + // If ref doesn't exist yet, create it. const createApi = `repos/${repo}/git/refs`; - run('gh', ['api', '-X', 'POST', createApi, '-f', `ref=refs/heads/${target}`, '-f', `sha=${sourceSha}`], { env: ghEnv }); + run('gh', ['api', '-X', 'POST', createApi, '-f', `ref=refs/heads/${target}`, '-f', `sha=${sourceSha}`], { + env: ghEnv, + }); } } main(); - diff --git a/scripts/pipeline/github/publish-release.mjs b/scripts/pipeline/github/publish-release.mjs index d64d352fd..9eae3d7a1 100644 --- a/scripts/pipeline/github/publish-release.mjs +++ b/scripts/pipeline/github/publish-release.mjs @@ -5,6 +5,8 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { buildRollingReleaseEditArgs } from './lib/gh-release-commands.mjs'; + function fail(message) { console.error(message); process.exit(1); @@ -250,7 +252,7 @@ function main() { } } - run('gh', ['release', 'edit', tag, '--title', title, '--notes', body], { env: ghEnv, dryRun }); + run('gh', buildRollingReleaseEditArgs({ tag, title, notes: body, targetSha: sha }), { env: ghEnv, dryRun }); } // Prune assets (rolling tags typically). diff --git a/scripts/pipeline/github/resolve-github-repo-slug.mjs b/scripts/pipeline/github/resolve-github-repo-slug.mjs new file mode 100644 index 000000000..a5b1e815c --- /dev/null +++ b/scripts/pipeline/github/resolve-github-repo-slug.mjs @@ -0,0 +1,75 @@ +// @ts-check + +import { execFileSync } from 'node:child_process'; + +/** + * @param {string} raw + * @returns {string} + */ +function stripGitSuffix(raw) { + return raw.endsWith('.git') ? raw.slice(0, -'.git'.length) : raw; +} + +/** + * @param {string} remoteUrl + * @returns {string} + */ +function parseGitHubRepoSlugFromRemoteUrl(remoteUrl) { + const raw = String(remoteUrl ?? '').trim(); + if (!raw) return ''; + + // git@github.com:owner/repo(.git) + if (raw.startsWith('git@github.com:')) { + const rest = stripGitSuffix(raw.slice('git@github.com:'.length)); + const parts = rest.split('/').filter(Boolean); + if (parts.length >= 2) return `${parts[0]}/${parts[1]}`; + return ''; + } + + // ssh://git@github.com/owner/repo(.git) + // https://github.com/owner/repo(.git) + // https://<token>@github.com/owner/repo(.git) + try { + const url = new URL(raw); + if (url.hostname !== 'github.com') return ''; + const pathname = stripGitSuffix(url.pathname.replace(/^\/+/, '')); + const parts = pathname.split('/').filter(Boolean); + if (parts.length >= 2) return `${parts[0]}/${parts[1]}`; + return ''; + } catch { + return ''; + } +} + +/** + * Best-effort resolution of the `owner/repo` slug for the current repo. + * Used to build stable GitHub release asset URLs when running pipeline scripts locally. + * + * Order: + * 1) `GH_REPO` (preferred) + * 2) `GITHUB_REPOSITORY` (Actions) + * 3) `git config --get remote.origin.url` (local) + * + * @param {{ repoRoot: string; env?: Record<string, string | undefined> }} opts + * @returns {string} + */ +export function resolveGitHubRepoSlug(opts) { + const env = opts.env ?? /** @type {any} */ (process.env); + const fromEnv = String(env.GH_REPO ?? env.GITHUB_REPOSITORY ?? '').trim(); + if (fromEnv) return fromEnv; + + try { + const remote = String( + execFileSync('git', ['config', '--get', 'remote.origin.url'], { + cwd: opts.repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5_000, + }) ?? '', + ).trim(); + return parseGitHubRepoSlugFromRemoteUrl(remote); + } catch { + return ''; + } +} + diff --git a/scripts/pipeline/npm/release-packages.mjs b/scripts/pipeline/npm/release-packages.mjs index f538ce14d..0b2039dfc 100644 --- a/scripts/pipeline/npm/release-packages.mjs +++ b/scripts/pipeline/npm/release-packages.mjs @@ -21,6 +21,19 @@ function parseBool(value, name) { fail(`${name} must be 'true' or 'false' (got: ${value})`); } +/** + * @param {unknown} value + * @param {string} name + * @param {boolean} autoValue + */ +function resolveAutoBool(value, name, autoValue) { + const raw = String(value ?? '').trim().toLowerCase(); + if (raw === 'auto') return autoValue; + if (raw === 'true') return true; + if (raw === 'false') return false; + fail(`${name} must be 'true', 'false', or 'auto' (got: ${value})`); +} + /** * @param {string} repoRoot * @param {string} rel @@ -198,7 +211,7 @@ function main() { 'publish-stack': { type: 'string', default: 'false' }, 'publish-server': { type: 'string', default: 'false' }, 'server-runner-dir': { type: 'string', default: 'packages/relay-server' }, - 'run-tests': { type: 'string', default: 'true' }, + 'run-tests': { type: 'string', default: 'auto' }, mode: { type: 'string', default: 'pack+publish' }, 'dry-run': { type: 'boolean', default: false }, }, @@ -215,7 +228,7 @@ function main() { const publishStack = parseBool(values['publish-stack'], '--publish-stack'); const publishServer = parseBool(values['publish-server'], '--publish-server'); const runnerDir = String(values['server-runner-dir'] ?? '').trim() || 'packages/relay-server'; - const runTests = parseBool(values['run-tests'], '--run-tests'); + const runTests = resolveAutoBool(values['run-tests'], '--run-tests', process.env.GITHUB_ACTIONS === 'true'); const mode = String(values.mode ?? '').trim() || 'pack+publish'; const dryRun = values['dry-run'] === true; diff --git a/scripts/pipeline/npm/set-preview-versions.mjs b/scripts/pipeline/npm/set-preview-versions.mjs index 96123cf28..10d762115 100644 --- a/scripts/pipeline/npm/set-preview-versions.mjs +++ b/scripts/pipeline/npm/set-preview-versions.mjs @@ -63,6 +63,7 @@ function main() { 'publish-stack': { type: 'string', default: 'false' }, 'publish-server': { type: 'string', default: 'false' }, 'server-runner-dir': { type: 'string', default: 'packages/relay-server' }, + write: { type: 'string', default: 'true' }, }, allowPositionals: false, }); @@ -72,6 +73,7 @@ function main() { const publishStack = parseBoolString(values['publish-stack'], '--publish-stack'); const publishServer = parseBoolString(values['publish-server'], '--publish-server'); const serverRunnerDir = String(values['server-runner-dir'] ?? '').trim() || 'packages/relay-server'; + const shouldWrite = parseBoolString(values.write, '--write'); const runRaw = String(process.env.GITHUB_RUN_NUMBER ?? '').trim(); const attemptRaw = String(process.env.GITHUB_RUN_ATTEMPT ?? '').trim(); @@ -88,20 +90,26 @@ function main() { if (publishCli) { const base = normalizeBase(readPackageVersion(repoRoot, path.join('apps', 'cli', 'package.json'))); versions.cli = `${base}-preview.${run}.${attempt}`; - writePackageVersion(repoRoot, path.join('apps', 'cli', 'package.json'), versions.cli); + if (shouldWrite) { + writePackageVersion(repoRoot, path.join('apps', 'cli', 'package.json'), versions.cli); + } } if (publishStack) { const base = normalizeBase(readPackageVersion(repoRoot, path.join('apps', 'stack', 'package.json'))); versions.stack = `${base}-preview.${run}.${attempt}`; - writePackageVersion(repoRoot, path.join('apps', 'stack', 'package.json'), versions.stack); + if (shouldWrite) { + writePackageVersion(repoRoot, path.join('apps', 'stack', 'package.json'), versions.stack); + } } if (publishServer) { if (!serverRunnerDir) fail('--server-runner-dir is required when --publish-server true'); const base = normalizeBase(readPackageVersion(repoRoot, path.join(serverRunnerDir, 'package.json'))); versions.server = `${base}-preview.${run}.${attempt}`; - writePackageVersion(repoRoot, path.join(serverRunnerDir, 'package.json'), versions.server); + if (shouldWrite) { + writePackageVersion(repoRoot, path.join(serverRunnerDir, 'package.json'), versions.server); + } } process.stdout.write(`${JSON.stringify(versions)}\n`); diff --git a/scripts/pipeline/release/compute-deploy-plan.mjs b/scripts/pipeline/release/compute-deploy-plan.mjs index a24190e32..a50737488 100644 --- a/scripts/pipeline/release/compute-deploy-plan.mjs +++ b/scripts/pipeline/release/compute-deploy-plan.mjs @@ -100,7 +100,7 @@ function anyMatch(patterns, paths) { function fetchRefs(cwd, remote, refs, required) { if (refs.length === 0) return; for (const ref of refs) { - run('git', ['fetch', remote, ref, '--prune', '--tags'], { cwd, stdio: 'pipe', allowFailure: !required }); + run('git', ['fetch', remote, ref, '--prune', '--no-tags'], { cwd, stdio: 'pipe', allowFailure: !required }); } } diff --git a/scripts/pipeline/release/lib/release-orchestrator.mjs b/scripts/pipeline/release/lib/release-orchestrator.mjs new file mode 100644 index 000000000..539d2bd06 --- /dev/null +++ b/scripts/pipeline/release/lib/release-orchestrator.mjs @@ -0,0 +1,175 @@ +// @ts-check + +/** + * This module centralizes the release orchestrator decision logic so it can be reused + * by local operators and GitHub workflows without duplicating conditional expressions. + */ + +/** + * @typedef {'preview'|'production'} DeployEnvironment + * @typedef {'ui'|'server'|'website'|'docs'|'cli'|'stack'|'server_runner'} DeployTarget + * @typedef {'none'|'patch'|'minor'|'major'} Bump + * @typedef {'none'|'ota'|'native'|'native_submit'} UiExpoAction + * @typedef {'none'|'build_only'|'build_and_publish'} DesktopMode + * + * @typedef {{ + * changed_ui: boolean; + * changed_cli: boolean; + * changed_server: boolean; + * changed_website: boolean; + * changed_docs: boolean; + * changed_shared: boolean; + * changed_stack: boolean; + * }} ChangedComponents + * + * @typedef {{ + * bump_app: Bump; + * bump_cli: Bump; + * bump_stack: Bump; + * bump_server: Bump; + * bump_website: Bump; + * should_bump: boolean; + * publish_cli: boolean; + * publish_stack: boolean; + * publish_server: boolean; + * }} BumpPlan + * + * @typedef {{ + * deploy_ui: { needed: boolean }; + * deploy_server: { needed: boolean }; + * deploy_website: { needed: boolean }; + * deploy_docs: { needed: boolean }; + * }} DeployPlan + * + * @typedef {{ + * runBumpVersionsDev: boolean; + * runPromoteMain: boolean; + * runSyncDevFromMain: boolean; + * runDeployUi: boolean; + * runDeployServer: boolean; + * runDeployWebsite: boolean; + * runDeployDocs: boolean; + * runPublishServerRuntime: boolean; + * runPublishUiWeb: boolean; + * runPublishDocker: boolean; + * runPublishNpm: boolean; + * runPublishCliBinaries: boolean; + * runPublishHstackBinaries: boolean; + * dockerBuildRelay: boolean; + * dockerBuildDevBox: boolean; + * }} ReleaseExecutionPlan + */ + +/** + * @param {DeployTarget[]} deployTargets + * @param {DeployTarget} target + */ +function hasTarget(deployTargets, target) { + return deployTargets.includes(target); +} + +/** + * Compute which jobs should run, mirroring `.github/workflows/release.yml` job `if:` conditions. + * + * Notes: + * - This function is intentionally side-effect free; it only decides what to run. + * - UI web deploy is production-only in the current policy (preview UI web deploys are treated as production). + * + * @param {{ + * environment: DeployEnvironment; + * dryRun: boolean; + * forceDeploy: boolean; + * deployTargets: DeployTarget[]; + * uiExpoAction: UiExpoAction; + * desktopMode: DesktopMode; + * changed: ChangedComponents; + * bumpPlan: BumpPlan; + * deployPlan?: DeployPlan | null; + * }} input + * @returns {ReleaseExecutionPlan} + */ +export function computeReleaseExecutionPlan(input) { + const env = input.environment; + const dryRun = input.dryRun; + const forceDeploy = input.forceDeploy; + const targets = input.deployTargets; + const changed = input.changed; + const bumpPlan = input.bumpPlan; + const deployPlan = input.deployPlan ?? null; + + const hasUi = hasTarget(targets, 'ui'); + const hasServer = hasTarget(targets, 'server'); + const hasWebsite = hasTarget(targets, 'website'); + const hasDocs = hasTarget(targets, 'docs'); + const hasCli = hasTarget(targets, 'cli'); + const hasStack = hasTarget(targets, 'stack'); + const hasServerRunner = hasTarget(targets, 'server_runner'); + + const runBumpVersionsDev = !dryRun && bumpPlan.should_bump === true; + const runPromoteMain = !dryRun && env === 'production'; + const runSyncDevFromMain = !dryRun && env === 'production'; + + // Deploy plan is computed only for enabled deploy branches. + const deployUiNeeded = Boolean(deployPlan?.deploy_ui?.needed); + const deployServerNeeded = Boolean(deployPlan?.deploy_server?.needed); + const deployWebsiteNeeded = Boolean(deployPlan?.deploy_website?.needed); + const deployDocsNeeded = Boolean(deployPlan?.deploy_docs?.needed); + + const uiExpoAction = input.uiExpoAction; + const desktopMode = input.desktopMode; + + // Mirrors release.yml: preview UI deploy branch promotion is disabled; UI deploy job is still used for Expo/desktop. + const wantsUiWork = + hasUi && + !dryRun && + (env === 'production' + ? (deployUiNeeded || bumpPlan.bump_app !== 'none' || forceDeploy) + : uiExpoAction !== 'none' || desktopMode !== 'none'); + + const runDeployUi = wantsUiWork; + const runDeployServer = hasServer && !dryRun && (deployServerNeeded || bumpPlan.bump_server !== 'none' || forceDeploy); + const runDeployWebsite = hasWebsite && !dryRun && (deployWebsiteNeeded || bumpPlan.bump_website !== 'none' || forceDeploy); + const runDeployDocs = hasDocs && !dryRun && (deployDocsNeeded || forceDeploy); + + const runPublishServerRuntime = !dryRun && env === 'preview' && hasServerRunner; + const runPublishUiWeb = !dryRun && env === 'preview' && hasUi; + + const runPublishDocker = + !dryRun && + env === 'preview' && + (forceDeploy || + changed.changed_ui || + changed.changed_server || + changed.changed_cli || + changed.changed_stack || + changed.changed_shared); + + const dockerBuildRelay = forceDeploy || changed.changed_ui || changed.changed_server || changed.changed_shared; + const dockerBuildDevBox = forceDeploy || changed.changed_cli || changed.changed_stack || changed.changed_shared; + + // `release.yml` routes npm publishing through `release-npm.yml` when any publish_* is true. + const runPublishNpm = !dryRun && (bumpPlan.publish_cli || bumpPlan.publish_stack || bumpPlan.publish_server); + + // Local parity: publishing CLI/stack via the release orchestrator also publishes their rolling GitHub releases. + const runPublishCliBinaries = !dryRun && hasCli; + const runPublishHstackBinaries = !dryRun && hasStack; + + return { + runBumpVersionsDev, + runPromoteMain, + runSyncDevFromMain, + runDeployUi, + runDeployServer, + runDeployWebsite, + runDeployDocs, + runPublishServerRuntime, + runPublishUiWeb, + runPublishDocker, + runPublishNpm, + runPublishCliBinaries, + runPublishHstackBinaries, + dockerBuildRelay, + dockerBuildDevBox, + }; +} + diff --git a/scripts/pipeline/release/lib/rolling-release-notes.mjs b/scripts/pipeline/release/lib/rolling-release-notes.mjs new file mode 100644 index 000000000..5daaba709 --- /dev/null +++ b/scripts/pipeline/release/lib/rolling-release-notes.mjs @@ -0,0 +1,21 @@ +// @ts-check + +/** + * Ensure rolling release notes include an explicit "Current version" marker. + * This helps operators quickly see what's deployed on a rolling tag like `cli-preview`. + * + * @param {string} notes + * @param {string} version + * @returns {string} + */ +export function withCurrentVersionLine(notes, version) { + const v = String(version ?? '').trim(); + if (!v) throw new Error('version is required'); + const line = `Current version: v${v}`; + + const base = String(notes ?? '').trimEnd(); + if (!base) return line; + if (base.includes(line)) return base; + return `${base}\n\n${line}`; +} + diff --git a/scripts/pipeline/release/publish-cli-binaries.mjs b/scripts/pipeline/release/publish-cli-binaries.mjs new file mode 100644 index 000000000..6444a0e24 --- /dev/null +++ b/scripts/pipeline/release/publish-cli-binaries.mjs @@ -0,0 +1,404 @@ +// @ts-check + +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdir, rm } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; + +import { prepareMinisignSecretKeyFile } from './lib/binary-release.mjs'; +import { withCurrentVersionLine } from './lib/rolling-release-notes.mjs'; +import { resolveGitHubRepoSlug } from '../github/resolve-github-repo-slug.mjs'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {unknown} value + * @param {string} name + */ +function parseBool(value, name) { + const raw = String(value ?? '').trim().toLowerCase(); + if (raw === 'true') return true; + if (raw === 'false') return false; + fail(`${name} must be 'true' or 'false' (got: ${value})`); +} + +/** + * @param {unknown} value + * @param {string} name + * @param {boolean} autoValue + */ +function resolveAutoBool(value, name, autoValue) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw || raw === 'auto') return autoValue; + return parseBool(raw, name); +} + +/** + * @param {string} repoRoot + * @param {string} rel + */ +function withinRepo(repoRoot, rel) { + return path.resolve(repoRoot, rel); +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cmd + * @param {string[]} args + * @param {{ cwd?: string; env?: Record<string, string>; stdio?: 'inherit' | 'pipe' }} [extra] + * @returns {string} + */ +function run(opts, cmd, args, extra) { + const cwd = extra?.cwd ? path.resolve(extra.cwd) : process.cwd(); + const printable = `${cmd} ${args.map((a) => (a.includes(' ') ? JSON.stringify(a) : a)).join(' ')}`; + if (opts.dryRun) { + console.log(`[dry-run] (cwd: ${cwd}) ${printable}`); + return ''; + } + + return execFileSync(cmd, args, { + cwd, + env: { ...process.env, ...(extra?.env ?? {}) }, + encoding: 'utf8', + stdio: extra?.stdio ?? 'inherit', + timeout: 30 * 60_000, + }); +} + +/** + * @param {string} version + */ +function normalizeBase(version) { + const m = String(version ?? '') + .trim() + .match(/^(\d+)\.(\d+)\.(\d+)/); + if (!m) fail(`Invalid version: ${version}`); + return `${m[1]}.${m[2]}.${m[3]}`; +} + +/** + * @param {string} pkgJsonPath + * @param {string} nextVersion + * @returns {() => void} + */ +function patchPackageVersion(pkgJsonPath, nextVersion) { + const raw = fs.readFileSync(pkgJsonPath, 'utf8'); + const parsed = JSON.parse(raw); + const prevVersion = String(parsed.version ?? '').trim(); + if (!prevVersion) fail(`package.json missing version: ${pkgJsonPath}`); + parsed.version = nextVersion; + fs.writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8'); + return () => { + fs.writeFileSync(pkgJsonPath, raw, 'utf8'); + }; +} + +/** + * @param {string} repoRoot + */ +function readCliVersion(repoRoot) { + const pkgJsonPath = withinRepo(repoRoot, path.join('apps', 'cli', 'package.json')); + const parsed = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const version = String(parsed?.version ?? '').trim(); + if (!version) fail(`package.json missing version: ${path.relative(repoRoot, pkgJsonPath)}`); + return version; +} + +/** + * Derive a stable preview prerelease suffix matching CI conventions. + * @returns {string} + */ +function resolvePreviewSuffix() { + const runRaw = String(process.env.GITHUB_RUN_NUMBER ?? '').trim(); + const attemptRaw = String(process.env.GITHUB_RUN_ATTEMPT ?? '').trim(); + + const runNumber = runRaw ? Number(runRaw) : NaN; + const attemptNumber = attemptRaw ? Number(attemptRaw) : NaN; + + const run = Number.isFinite(runNumber) ? Math.max(0, Math.floor(runNumber)) : Math.floor(Date.now() / 1000); + const attempt = Number.isFinite(attemptNumber) ? Math.max(1, Math.floor(attemptNumber)) : Math.max(1, Math.floor(process.pid)); + return `preview.${run}.${attempt}`; +} + +/** + * @param {string} repoRoot + * @param {{ dryRun: boolean }} opts + */ +function ensureMinisign(repoRoot, opts) { + const bootstrap = withinRepo(repoRoot, '.github/actions/bootstrap-minisign/bootstrap-minisign.sh'); + if (!fs.existsSync(bootstrap)) fail(`Missing minisign bootstrap script: ${path.relative(repoRoot, bootstrap)}`); + const out = run(opts, 'bash', [bootstrap], { cwd: repoRoot, stdio: 'pipe' }).trim(); + if (out) { + process.env.PATH = `${out}${path.delimiter}${process.env.PATH ?? ''}`; + } +} + +async function preflightMinisignKey({ dryRun }) { + if (dryRun) return; + const keyRaw = String(process.env.MINISIGN_SECRET_KEY ?? '').trim(); + if (!keyRaw) { + fail('[pipeline] MINISIGN_SECRET_KEY is required to publish signed CLI release artifacts.'); + } + const prepared = await prepareMinisignSecretKeyFile(keyRaw); + if (prepared.temp) { + await rm(prepared.cleanupPath ?? prepared.path, { recursive: true, force: true }); + } +} + +/** + * publish-release uploads every file under --assets-dir. Make sure we start from a clean directory so + * we don't accidentally re-upload stale artifacts from previous local runs. + * @param {string} repoRoot + * @param {{ dryRun: boolean }} opts + */ +async function ensureCleanArtifactsDir(repoRoot, opts) { + const rel = 'dist/release-assets/cli'; + const abs = withinRepo(repoRoot, rel); + const prefix = opts.dryRun ? '[dry-run]' : '[pipeline]'; + console.log(`${prefix} clean artifacts dir: ${rel}`); + if (opts.dryRun) return; + await rm(abs, { recursive: true, force: true }); + await mkdir(abs, { recursive: true }); +} + +async function main() { + const repoRoot = path.resolve(process.cwd()); + const { values } = parseArgs({ + options: { + channel: { type: 'string' }, + 'allow-stable': { type: 'string', default: 'false' }, + 'release-message': { type: 'string', default: '' }, + 'run-contracts': { type: 'string', default: 'auto' }, + 'check-installers': { type: 'string', default: 'true' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const channel = String(values.channel ?? '').trim(); + if (!channel) fail('--channel is required'); + if (channel !== 'preview' && channel !== 'stable') { + fail(`--channel must be 'preview' or 'stable' (got: ${channel})`); + } + const allowStable = parseBool(values['allow-stable'], '--allow-stable'); + if (channel === 'stable' && !allowStable) { + fail('Stable CLI binary publishing is disabled. Re-run with --allow-stable true if intentional.'); + } + + const dryRun = values['dry-run'] === true; + const runContracts = resolveAutoBool(values['run-contracts'], '--run-contracts', process.env.GITHUB_ACTIONS === 'true'); + const checkInstallers = parseBool(values['check-installers'], '--check-installers'); + const releaseMessage = String(values['release-message'] ?? '').trim(); + + const opts = { dryRun }; + + const embeddedPolicy = channel === 'stable' ? 'production' : 'preview'; + + const rollingTag = channel === 'preview' ? 'cli-preview' : 'cli-stable'; + const rollingTitle = channel === 'preview' ? 'Happier CLI Preview' : 'Happier CLI Stable'; + const prerelease = channel === 'preview' ? 'true' : 'false'; + const notesBase = channel === 'preview' ? 'Rolling preview CLI binaries.' : 'Rolling stable CLI binaries.'; + + const targetSha = run(opts, 'git', ['rev-parse', 'HEAD'], { cwd: repoRoot, stdio: 'pipe' }).trim() || 'UNKNOWN_SHA'; + + console.log(`[pipeline] cli-binaries: channel=${channel} tag=${rollingTag}`); + + await preflightMinisignKey(opts); + + if (runContracts) { + run(opts, 'yarn', ['-s', 'test:release:contracts'], { + cwd: repoRoot, + env: { ...process.env, HAPPIER_EMBEDDED_POLICY_ENV: embeddedPolicy }, + }); + } + if (checkInstallers) { + run(opts, process.execPath, ['scripts/pipeline/release/sync-installers.mjs', '--check'], { cwd: repoRoot }); + } + + ensureMinisign(repoRoot, opts); + + const cliPkgJson = withinRepo(repoRoot, path.join('apps', 'cli', 'package.json')); + const originalVersion = readCliVersion(repoRoot); + const base = normalizeBase(originalVersion); + const version = channel === 'preview' ? `${base}-${resolvePreviewSuffix()}` : originalVersion; + const notes = withCurrentVersionLine(notesBase, version); + + /** @type {null | (() => void)} */ + let restoreVersion = null; + try { + if (channel === 'preview') { + if (dryRun) { + console.log(`[dry-run] patch ${path.relative(repoRoot, cliPkgJson)} version -> ${version}`); + } else { + restoreVersion = patchPackageVersion(cliPkgJson, version); + } + } + + await ensureCleanArtifactsDir(repoRoot, opts); + + run( + opts, + process.execPath, + ['scripts/pipeline/release/build-cli-binaries.mjs', '--channel', channel, '--version', version], + { + cwd: repoRoot, + env: { + ...process.env, + HAPPIER_EMBEDDED_POLICY_ENV: process.env.HAPPIER_EMBEDDED_POLICY_ENV ?? embeddedPolicy, + }, + }, + ); + + const artifactsDir = withinRepo(repoRoot, 'dist/release-assets/cli'); + const checksums = withinRepo(repoRoot, `dist/release-assets/cli/checksums-happier-v${version}.txt`); + const signature = withinRepo(repoRoot, `dist/release-assets/cli/checksums-happier-v${version}.txt.minisig`); + const manifestPath = withinRepo(repoRoot, `dist/release-assets/cli/manifests/v1/happier/${channel}/latest.json`); + + const repoSlug = resolveGitHubRepoSlug({ repoRoot, env: process.env }); + if (!repoSlug) { + fail( + [ + 'Unable to resolve GitHub repo slug for manifest URL generation.', + 'Set GH_REPO=owner/repo (recommended) or ensure git remote.origin.url points at github.com.', + ].join('\n'), + ); + } + const assetsBaseUrl = `https://github.com/${repoSlug}/releases/download/${rollingTag}`; + + run( + opts, + process.execPath, + [ + 'scripts/pipeline/release/publish-manifests.mjs', + '--product=happier', + '--channel', + channel, + '--version', + version, + '--artifacts-dir', + 'dist/release-assets/cli', + '--out-dir', + 'dist/release-assets/cli/manifests', + '--assets-base-url', + assetsBaseUrl, + '--commit-sha', + targetSha, + '--workflow-run-id', + String(process.env.GITHUB_RUN_ID ?? ''), + ], + { cwd: repoRoot }, + ); + + if (!dryRun) { + for (const p of [checksums, signature, manifestPath]) { + if (!fs.existsSync(p)) fail(`Missing expected artifact: ${path.relative(repoRoot, p)}`); + } + } else { + console.log(`[dry-run] would verify artifacts under ${path.relative(repoRoot, artifactsDir)}`); + } + + run( + opts, + process.execPath, + [ + 'scripts/pipeline/release/verify-artifacts.mjs', + '--artifacts-dir', + path.relative(repoRoot, artifactsDir), + '--checksums', + path.relative(repoRoot, checksums), + '--public-key', + 'scripts/release/installers/happier-release.pub', + '--skip-smoke', + ], + { cwd: repoRoot }, + ); + + run( + opts, + process.execPath, + [ + 'scripts/pipeline/github/publish-release.mjs', + '--tag', + rollingTag, + '--title', + rollingTitle, + '--target-sha', + targetSha, + '--prerelease', + prerelease, + '--rolling-tag', + 'true', + '--generate-notes', + 'false', + '--notes', + notes, + '--assets-dir', + path.relative(repoRoot, artifactsDir), + '--clobber', + 'true', + '--prune-assets', + 'true', + '--release-message', + releaseMessage, + ...(dryRun ? ['--dry-run'] : []), + ], + { cwd: repoRoot }, + ); + if (!dryRun) { + console.log(`[pipeline] published GitHub rolling release: ${rollingTag}`); + console.log(`[pipeline] note: GitHub may not update 'Published' timestamps for rolling releases; verify assets on tag '${rollingTag}'.`); + } + + const versionTag = `cli-v${version}`; + const versionTitle = `Happier CLI v${version}`; + const versionNotes = channel === 'preview' ? `CLI preview build v${version}.` : `CLI stable release v${version}.`; + + run( + opts, + process.execPath, + [ + 'scripts/pipeline/github/publish-release.mjs', + '--tag', + versionTag, + '--title', + versionTitle, + '--target-sha', + targetSha, + '--prerelease', + prerelease, + '--rolling-tag', + 'false', + '--generate-notes', + 'false', + '--notes', + versionNotes, + '--assets-dir', + path.relative(repoRoot, artifactsDir), + '--clobber', + 'true', + '--prune-assets', + 'true', + '--release-message', + releaseMessage, + ...(dryRun ? ['--dry-run'] : []), + ], + { cwd: repoRoot }, + ); + if (!dryRun) { + console.log(`[pipeline] published GitHub versioned release: ${versionTag}`); + } + } finally { + if (restoreVersion) { + restoreVersion(); + } + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/pipeline/release/publish-hstack-binaries.mjs b/scripts/pipeline/release/publish-hstack-binaries.mjs new file mode 100644 index 000000000..63594b85c --- /dev/null +++ b/scripts/pipeline/release/publish-hstack-binaries.mjs @@ -0,0 +1,397 @@ +// @ts-check + +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdir, rm } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; + +import { prepareMinisignSecretKeyFile } from './lib/binary-release.mjs'; +import { withCurrentVersionLine } from './lib/rolling-release-notes.mjs'; +import { resolveGitHubRepoSlug } from '../github/resolve-github-repo-slug.mjs'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {unknown} value + * @param {string} name + */ +function parseBool(value, name) { + const raw = String(value ?? '').trim().toLowerCase(); + if (raw === 'true') return true; + if (raw === 'false') return false; + fail(`${name} must be 'true' or 'false' (got: ${value})`); +} + +/** + * @param {unknown} value + * @param {string} name + * @param {boolean} autoValue + */ +function resolveAutoBool(value, name, autoValue) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw || raw === 'auto') return autoValue; + return parseBool(raw, name); +} + +/** + * @param {string} repoRoot + * @param {string} rel + */ +function withinRepo(repoRoot, rel) { + return path.resolve(repoRoot, rel); +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cmd + * @param {string[]} args + * @param {{ cwd?: string; env?: Record<string, string>; stdio?: 'inherit' | 'pipe' }} [extra] + * @returns {string} + */ +function run(opts, cmd, args, extra) { + const cwd = extra?.cwd ? path.resolve(extra.cwd) : process.cwd(); + const printable = `${cmd} ${args.map((a) => (a.includes(' ') ? JSON.stringify(a) : a)).join(' ')}`; + if (opts.dryRun) { + console.log(`[dry-run] (cwd: ${cwd}) ${printable}`); + return ''; + } + + return execFileSync(cmd, args, { + cwd, + env: { ...process.env, ...(extra?.env ?? {}) }, + encoding: 'utf8', + stdio: extra?.stdio ?? 'inherit', + timeout: 30 * 60_000, + }); +} + +/** + * @param {string} version + */ +function normalizeBase(version) { + const m = String(version ?? '') + .trim() + .match(/^(\d+)\.(\d+)\.(\d+)/); + if (!m) fail(`Invalid version: ${version}`); + return `${m[1]}.${m[2]}.${m[3]}`; +} + +/** + * @param {string} pkgJsonPath + * @param {string} nextVersion + * @returns {() => void} + */ +function patchPackageVersion(pkgJsonPath, nextVersion) { + const raw = fs.readFileSync(pkgJsonPath, 'utf8'); + const parsed = JSON.parse(raw); + const prevVersion = String(parsed.version ?? '').trim(); + if (!prevVersion) fail(`package.json missing version: ${pkgJsonPath}`); + parsed.version = nextVersion; + fs.writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8'); + return () => { + fs.writeFileSync(pkgJsonPath, raw, 'utf8'); + }; +} + +/** + * @param {string} repoRoot + */ +function readHstackVersion(repoRoot) { + const pkgJsonPath = withinRepo(repoRoot, path.join('apps', 'stack', 'package.json')); + const parsed = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const version = String(parsed?.version ?? '').trim(); + if (!version) fail(`package.json missing version: ${path.relative(repoRoot, pkgJsonPath)}`); + return version; +} + +/** + * Derive a stable preview prerelease suffix matching CI conventions. + * @returns {string} + */ +function resolvePreviewSuffix() { + const runRaw = String(process.env.GITHUB_RUN_NUMBER ?? '').trim(); + const attemptRaw = String(process.env.GITHUB_RUN_ATTEMPT ?? '').trim(); + + const runNumber = runRaw ? Number(runRaw) : NaN; + const attemptNumber = attemptRaw ? Number(attemptRaw) : NaN; + + const run = Number.isFinite(runNumber) ? Math.max(0, Math.floor(runNumber)) : Math.floor(Date.now() / 1000); + const attempt = Number.isFinite(attemptNumber) ? Math.max(1, Math.floor(attemptNumber)) : Math.max(1, Math.floor(process.pid)); + return `preview.${run}.${attempt}`; +} + +/** + * @param {string} repoRoot + * @param {{ dryRun: boolean }} opts + */ +function ensureMinisign(repoRoot, opts) { + const bootstrap = withinRepo(repoRoot, '.github/actions/bootstrap-minisign/bootstrap-minisign.sh'); + if (!fs.existsSync(bootstrap)) fail(`Missing minisign bootstrap script: ${path.relative(repoRoot, bootstrap)}`); + const out = run(opts, 'bash', [bootstrap], { cwd: repoRoot, stdio: 'pipe' }).trim(); + if (out) { + process.env.PATH = `${out}${path.delimiter}${process.env.PATH ?? ''}`; + } +} + +async function preflightMinisignKey({ dryRun }) { + if (dryRun) return; + const keyRaw = String(process.env.MINISIGN_SECRET_KEY ?? '').trim(); + if (!keyRaw) { + fail('[pipeline] MINISIGN_SECRET_KEY is required to publish signed hstack release artifacts.'); + } + const prepared = await prepareMinisignSecretKeyFile(keyRaw); + if (prepared.temp) { + await rm(prepared.cleanupPath ?? prepared.path, { recursive: true, force: true }); + } +} + +/** + * publish-release uploads every file under --assets-dir. Make sure we start from a clean directory so + * we don't accidentally re-upload stale artifacts from previous local runs. + * @param {string} repoRoot + * @param {{ dryRun: boolean }} opts + */ +async function ensureCleanArtifactsDir(repoRoot, opts) { + const rel = 'dist/release-assets/stack'; + const abs = withinRepo(repoRoot, rel); + const prefix = opts.dryRun ? '[dry-run]' : '[pipeline]'; + console.log(`${prefix} clean artifacts dir: ${rel}`); + if (opts.dryRun) return; + await rm(abs, { recursive: true, force: true }); + await mkdir(abs, { recursive: true }); +} + +async function main() { + const repoRoot = path.resolve(process.cwd()); + const { values } = parseArgs({ + options: { + channel: { type: 'string' }, + 'allow-stable': { type: 'string', default: 'false' }, + 'release-message': { type: 'string', default: '' }, + 'run-contracts': { type: 'string', default: 'auto' }, + 'check-installers': { type: 'string', default: 'true' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const channel = String(values.channel ?? '').trim(); + if (!channel) fail('--channel is required'); + if (channel !== 'preview' && channel !== 'stable') { + fail(`--channel must be 'preview' or 'stable' (got: ${channel})`); + } + const allowStable = parseBool(values['allow-stable'], '--allow-stable'); + if (channel === 'stable' && !allowStable) { + fail('Stable hstack binary publishing is disabled. Re-run with --allow-stable true if intentional.'); + } + + const dryRun = values['dry-run'] === true; + const runContracts = resolveAutoBool(values['run-contracts'], '--run-contracts', process.env.GITHUB_ACTIONS === 'true'); + const checkInstallers = parseBool(values['check-installers'], '--check-installers'); + const releaseMessage = String(values['release-message'] ?? '').trim(); + + const opts = { dryRun }; + + const embeddedPolicy = channel === 'stable' ? 'production' : 'preview'; + + const rollingTag = channel === 'preview' ? 'stack-preview' : 'stack-stable'; + const rollingTitle = channel === 'preview' ? 'Happier Stack Preview' : 'Happier Stack Stable'; + const prerelease = channel === 'preview' ? 'true' : 'false'; + const notesBase = channel === 'preview' ? 'Rolling preview hstack binaries.' : 'Rolling stable hstack binaries.'; + + const targetSha = run(opts, 'git', ['rev-parse', 'HEAD'], { cwd: repoRoot, stdio: 'pipe' }).trim() || 'UNKNOWN_SHA'; + + console.log(`[pipeline] hstack-binaries: channel=${channel} tag=${rollingTag}`); + + await preflightMinisignKey(opts); + + if (runContracts) { + run(opts, 'yarn', ['-s', 'test:release:contracts'], { + cwd: repoRoot, + env: { ...process.env, HAPPIER_EMBEDDED_POLICY_ENV: embeddedPolicy }, + }); + } + if (checkInstallers) { + run(opts, process.execPath, ['scripts/pipeline/release/sync-installers.mjs', '--check'], { cwd: repoRoot }); + } + + ensureMinisign(repoRoot, opts); + + const stackPkgJson = withinRepo(repoRoot, path.join('apps', 'stack', 'package.json')); + const originalVersion = readHstackVersion(repoRoot); + const base = normalizeBase(originalVersion); + const version = channel === 'preview' ? `${base}-${resolvePreviewSuffix()}` : originalVersion; + const notes = withCurrentVersionLine(notesBase, version); + + /** @type {null | (() => void)} */ + let restoreVersion = null; + try { + if (channel === 'preview') { + if (dryRun) { + console.log(`[dry-run] patch ${path.relative(repoRoot, stackPkgJson)} version -> ${version}`); + } else { + restoreVersion = patchPackageVersion(stackPkgJson, version); + } + } + + await ensureCleanArtifactsDir(repoRoot, opts); + + run( + opts, + process.execPath, + ['scripts/pipeline/release/build-hstack-binaries.mjs', '--channel', channel, '--version', version], + { + cwd: repoRoot, + env: { + ...process.env, + HAPPIER_EMBEDDED_POLICY_ENV: process.env.HAPPIER_EMBEDDED_POLICY_ENV ?? embeddedPolicy, + }, + }, + ); + + const artifactsDir = withinRepo(repoRoot, 'dist/release-assets/stack'); + const checksums = withinRepo(repoRoot, `dist/release-assets/stack/checksums-hstack-v${version}.txt`); + const signature = withinRepo(repoRoot, `dist/release-assets/stack/checksums-hstack-v${version}.txt.minisig`); + const manifestPath = withinRepo(repoRoot, `dist/release-assets/stack/manifests/v1/hstack/${channel}/latest.json`); + + const repoSlug = resolveGitHubRepoSlug({ repoRoot, env: process.env }); + if (!repoSlug) { + fail( + [ + 'Unable to resolve GitHub repo slug for manifest URL generation.', + 'Set GH_REPO=owner/repo (recommended) or ensure git remote.origin.url points at github.com.', + ].join('\n'), + ); + } + const assetsBaseUrl = `https://github.com/${repoSlug}/releases/download/${rollingTag}`; + + run( + opts, + process.execPath, + [ + 'scripts/pipeline/release/publish-manifests.mjs', + '--product=hstack', + '--channel', + channel, + '--version', + version, + '--artifacts-dir', + 'dist/release-assets/stack', + '--out-dir', + 'dist/release-assets/stack/manifests', + '--assets-base-url', + assetsBaseUrl, + '--commit-sha', + targetSha, + '--workflow-run-id', + String(process.env.GITHUB_RUN_ID ?? ''), + ], + { cwd: repoRoot }, + ); + + if (!dryRun) { + for (const p of [checksums, signature, manifestPath]) { + if (!fs.existsSync(p)) fail(`Missing expected artifact: ${path.relative(repoRoot, p)}`); + } + } else { + console.log(`[dry-run] would verify artifacts under ${path.relative(repoRoot, artifactsDir)}`); + } + + run( + opts, + process.execPath, + [ + 'scripts/pipeline/release/verify-artifacts.mjs', + '--artifacts-dir', + path.relative(repoRoot, artifactsDir), + '--checksums', + path.relative(repoRoot, checksums), + '--public-key', + 'scripts/release/installers/happier-release.pub', + '--skip-smoke', + ], + { cwd: repoRoot }, + ); + + run( + opts, + process.execPath, + [ + 'scripts/pipeline/github/publish-release.mjs', + '--tag', + rollingTag, + '--title', + rollingTitle, + '--target-sha', + targetSha, + '--prerelease', + prerelease, + '--rolling-tag', + 'true', + '--generate-notes', + 'false', + '--notes', + notes, + '--assets-dir', + path.relative(repoRoot, artifactsDir), + '--clobber', + 'true', + '--prune-assets', + 'true', + '--release-message', + releaseMessage, + ...(dryRun ? ['--dry-run'] : []), + ], + { cwd: repoRoot }, + ); + + const versionTag = `stack-v${version}`; + const versionTitle = `Happier Stack v${version}`; + const versionNotes = channel === 'preview' ? `hstack preview build v${version}.` : `hstack stable release v${version}.`; + + run( + opts, + process.execPath, + [ + 'scripts/pipeline/github/publish-release.mjs', + '--tag', + versionTag, + '--title', + versionTitle, + '--target-sha', + targetSha, + '--prerelease', + prerelease, + '--rolling-tag', + 'false', + '--generate-notes', + 'false', + '--notes', + versionNotes, + '--assets-dir', + path.relative(repoRoot, artifactsDir), + '--clobber', + 'true', + '--prune-assets', + 'true', + '--release-message', + releaseMessage, + ...(dryRun ? ['--dry-run'] : []), + ], + { cwd: repoRoot }, + ); + } finally { + if (restoreVersion) { + restoreVersion(); + } + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/pipeline/release/publish-server-runtime.mjs b/scripts/pipeline/release/publish-server-runtime.mjs index e0232f428..275a92876 100644 --- a/scripts/pipeline/release/publish-server-runtime.mjs +++ b/scripts/pipeline/release/publish-server-runtime.mjs @@ -7,6 +7,8 @@ import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { prepareMinisignSecretKeyFile } from './lib/binary-release.mjs'; +import { withCurrentVersionLine } from './lib/rolling-release-notes.mjs'; +import { resolveGitHubRepoSlug } from '../github/resolve-github-repo-slug.mjs'; function fail(message) { console.error(message); @@ -24,6 +26,17 @@ function parseBool(value, name) { fail(`${name} must be 'true' or 'false' (got: ${value})`); } +/** + * @param {unknown} value + * @param {string} name + * @param {boolean} autoValue + */ +function resolveAutoBool(value, name, autoValue) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw || raw === 'auto') return autoValue; + return parseBool(raw, name); +} + /** * @param {string} repoRoot * @param {string} rel @@ -104,7 +117,7 @@ async function main() { channel: { type: 'string' }, 'allow-stable': { type: 'string', default: 'false' }, 'release-message': { type: 'string', default: '' }, - 'run-contracts': { type: 'string', default: 'true' }, + 'run-contracts': { type: 'string', default: 'auto' }, 'check-installers': { type: 'string', default: 'true' }, 'dry-run': { type: 'boolean', default: false }, }, @@ -122,7 +135,7 @@ async function main() { } const dryRun = values['dry-run'] === true; - const runContracts = parseBool(values['run-contracts'], '--run-contracts'); + const runContracts = resolveAutoBool(values['run-contracts'], '--run-contracts', process.env.GITHUB_ACTIONS === 'true'); const checkInstallers = parseBool(values['check-installers'], '--check-installers'); const releaseMessage = String(values['release-message'] ?? '').trim(); @@ -135,7 +148,9 @@ async function main() { const tag = channel === 'preview' ? 'server-preview' : 'server-stable'; const title = channel === 'preview' ? 'Happier Server Preview' : 'Happier Server Stable'; const prerelease = channel === 'preview' ? 'true' : 'false'; - const notes = channel === 'preview' ? 'Rolling preview server runtime release.' : 'Stable server runtime release.'; + const notesBase = + channel === 'preview' ? 'Rolling preview server runtime release.' : 'Rolling stable server runtime release.'; + const notes = withCurrentVersionLine(notesBase, serverVersion); const versionTag = `server-v${serverVersion}`; const versionTitle = `Happier Server v${serverVersion}`; const versionNotes = @@ -177,11 +192,16 @@ async function main() { const signature = withinRepo(repoRoot, `dist/release-assets/server/checksums-happier-server-v${serverVersion}.txt.minisig`); const manifestPath = withinRepo(repoRoot, `dist/release-assets/server/manifests/v1/happier-server/${channel}/latest.json`); - const assetsBaseUrl = `https://github.com/${process.env.GH_REPO || process.env.GITHUB_REPOSITORY || ''}/releases/download/${tag}`; - if (!assetsBaseUrl.includes('/')) { - // Keep manifests generation possible in local dry-runs without GH_REPO set. - // The base URL is used for URLs inside the manifests; local callers can override GH_REPO. + const repoSlug = resolveGitHubRepoSlug({ repoRoot, env: process.env }); + if (!repoSlug) { + fail( + [ + 'Unable to resolve GitHub repo slug for manifest URL generation.', + 'Set GH_REPO=owner/repo (recommended) or ensure git remote.origin.url points at github.com.', + ].join('\n'), + ); } + const assetsBaseUrl = `https://github.com/${repoSlug}/releases/download/${tag}`; run( opts, diff --git a/scripts/pipeline/release/publish-ui-web.mjs b/scripts/pipeline/release/publish-ui-web.mjs index 43a57bfc0..aacc34c86 100644 --- a/scripts/pipeline/release/publish-ui-web.mjs +++ b/scripts/pipeline/release/publish-ui-web.mjs @@ -7,6 +7,7 @@ import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { prepareMinisignSecretKeyFile } from './lib/binary-release.mjs'; +import { withCurrentVersionLine } from './lib/rolling-release-notes.mjs'; function fail(message) { console.error(message); @@ -24,6 +25,17 @@ function parseBool(value, name) { fail(`${name} must be 'true' or 'false' (got: ${value})`); } +/** + * @param {unknown} value + * @param {string} name + * @param {boolean} autoValue + */ +function resolveAutoBool(value, name, autoValue) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw || raw === 'auto') return autoValue; + return parseBool(raw, name); +} + /** * @param {string} repoRoot * @param {string} rel @@ -123,7 +135,7 @@ async function main() { channel: { type: 'string' }, 'allow-stable': { type: 'string', default: 'false' }, 'release-message': { type: 'string', default: '' }, - 'run-contracts': { type: 'string', default: 'true' }, + 'run-contracts': { type: 'string', default: 'auto' }, 'check-installers': { type: 'string', default: 'true' }, 'dry-run': { type: 'boolean', default: false }, }, @@ -141,7 +153,7 @@ async function main() { } const dryRun = values['dry-run'] === true; - const runContracts = parseBool(values['run-contracts'], '--run-contracts'); + const runContracts = resolveAutoBool(values['run-contracts'], '--run-contracts', process.env.GITHUB_ACTIONS === 'true'); const checkInstallers = parseBool(values['check-installers'], '--check-installers'); const releaseMessage = String(values['release-message'] ?? '').trim(); @@ -155,7 +167,8 @@ async function main() { const tag = channel === 'preview' ? 'ui-web-preview' : 'ui-web-stable'; const title = channel === 'preview' ? 'Happier UI Web Bundle Preview' : 'Happier UI Web Bundle Stable'; const prerelease = channel === 'preview' ? 'true' : 'false'; - const notes = channel === 'preview' ? 'Rolling preview UI web bundle release.' : 'Stable UI web bundle release.'; + const notesBase = channel === 'preview' ? 'Rolling preview UI web bundle release.' : 'Rolling stable UI web bundle release.'; + const notes = withCurrentVersionLine(notesBase, uiVersion); const versionTag = `ui-web-v${uiVersion}`; const versionTitle = `Happier UI Web Bundle v${uiVersion}`; const versionNotes = diff --git a/scripts/pipeline/run.mjs b/scripts/pipeline/run.mjs index 460cded9d..b3458045d 100644 --- a/scripts/pipeline/run.mjs +++ b/scripts/pipeline/run.mjs @@ -7,12 +7,43 @@ import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { loadPipelineEnv } from './env/load-pipeline-env.mjs'; import { loadSecrets } from './secrets/load-secrets.mjs'; +import { assertCleanWorktree } from './git/ensure-clean-worktree.mjs'; +import { computeReleaseExecutionPlan } from './release/lib/release-orchestrator.mjs'; +import { createAnsiStyle } from './cli/ansi-style.mjs'; +import { renderCommandHelp, renderPipelineHelp } from './cli/help.mjs'; function fail(message) { console.error(message); process.exit(1); } +/** + * @param {string[]} rawArgv + */ +function parseGlobalCliFlags(rawArgv) { + /** @type {null | boolean} */ + let colorOverride = null; + + const argv = []; + for (const arg of rawArgv) { + if (arg === '--no-color') { + colorOverride = false; + continue; + } + if (arg === '--color') { + colorOverride = true; + continue; + } + argv.push(arg); + } + + const envNoColor = typeof process.env.NO_COLOR === 'string' && process.env.NO_COLOR.length >= 0; + const enabled = + colorOverride === true ? true : colorOverride === false ? false : Boolean(process.stdout.isTTY) && !envNoColor; + + return { argv, style: createAnsiStyle({ enabled }) }; +} + /** * @param {string} v * @returns {v is 'production' | 'preview'} @@ -75,6 +106,40 @@ function parseBoolString(value, name) { fail(`${name} must be 'true' or 'false' (got: ${value})`); } +/** + * Local operator escape hatch: when `--allow-dirty true` is used, we still require + * a clean index so pipeline-driven commits can't accidentally include staged changes. + * + * @param {{ cwd: string; allowDirty: boolean; dryRun: boolean }} opts + */ +function assertNoStagedChanges(opts) { + if (opts.dryRun) return; + if (!opts.allowDirty) return; + + const raw = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: opts.cwd, + env: process.env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }).trim(); + if (!raw) return; + + throw new Error( + [ + 'git index has staged changes; refusing to run release steps that may create commits.', + 'Fix: unstage changes or commit them separately before running the release pipeline.', + '', + raw + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + .map((p) => `- ${p}`) + .join('\n'), + ].join('\n'), + ); +} + /** * @param {unknown} value * @param {string} name @@ -86,6 +151,100 @@ function resolveAutoBool(value, name, autoValue) { return parseBoolString(raw, name); } +/** + * Split wrapper flags (owned by run.mjs) from passthrough args for wrapped scripts. + * We intentionally avoid node:util parseArgs here because with strict=false it treats unknown flags as booleans, + * consuming their values and breaking passthrough (e.g. `--channel preview` becomes `channel=true` + positional `preview`). + * + * @param {string[]} argv + * @returns {{ + * deployEnvironment: 'production' | 'preview'; + * dryRun: boolean; + * secretsSource: 'auto' | 'env' | 'keychain'; + * keychainService: string; + * keychainAccount: string; + * passthrough: string[]; + * }} + */ +function splitWrappedReleaseArgs(argv) { + /** @type {'production' | 'preview'} */ + let deployEnvironment = 'production'; + let dryRun = false; + /** @type {'auto' | 'env' | 'keychain'} */ + let secretsSource = 'auto'; + let keychainService = 'happier/pipeline'; + let keychainAccount = ''; + + /** @type {string[]} */ + const passthrough = []; + + const takeValue = (arg, i) => { + if (arg.includes('=')) { + const idx = arg.indexOf('='); + return { value: arg.slice(idx + 1), nextIndex: i }; + } + const next = argv[i + 1]; + if (next == null) { + fail(`Missing value for ${arg}`); + } + return { value: next, nextIndex: i + 1 }; + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = String(argv[i] ?? ''); + + if (arg === '--dry-run') { + dryRun = true; + continue; + } + if (arg.startsWith('--dry-run=')) { + const { value } = takeValue(arg, i); + dryRun = parseBoolString(value, '--dry-run'); + continue; + } + + if (arg === '--deploy-environment' || arg.startsWith('--deploy-environment=')) { + const { value, nextIndex } = takeValue(arg, i); + i = nextIndex; + const v = String(value ?? '').trim(); + if (!isDeployEnvironment(v)) { + fail(`--deploy-environment must be 'production' or 'preview' (got: ${v || '<empty>'})`); + } + deployEnvironment = v; + continue; + } + + if (arg === '--secrets-source' || arg.startsWith('--secrets-source=')) { + const { value, nextIndex } = takeValue(arg, i); + i = nextIndex; + const raw = String(value ?? '').trim(); + if (raw === 'auto' || raw === 'env' || raw === 'keychain') { + secretsSource = raw; + continue; + } + fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${raw})`); + } + + if (arg === '--keychain-service' || arg.startsWith('--keychain-service=')) { + const { value, nextIndex } = takeValue(arg, i); + i = nextIndex; + keychainService = String(value ?? '').trim() || 'happier/pipeline'; + continue; + } + + if (arg === '--keychain-account' || arg.startsWith('--keychain-account=')) { + const { value, nextIndex } = takeValue(arg, i); + i = nextIndex; + keychainAccount = String(value ?? '').trim(); + continue; + } + + passthrough.push(arg); + } + + return { deployEnvironment, dryRun, secretsSource, keychainService, keychainAccount, passthrough }; +} + function repoRootFromHere() { const here = path.dirname(fileURLToPath(import.meta.url)); return path.resolve(here, '..', '..'); @@ -139,6 +298,22 @@ function runNpmReleasePackages({ repoRoot, env, args, dryRun }) { }); } +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runNpmSetPreviewVersions({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'npm', 'set-preview-versions.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + /** * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts */ @@ -155,6 +330,86 @@ function runPublishUiWeb({ repoRoot, env, args, dryRun }) { }); } +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runPublishCliBinaries({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'release', 'publish-cli-binaries.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runPublishHstackBinaries({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'release', 'publish-hstack-binaries.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runChecksPlan({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'checks', 'resolve-checks-plan.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runChecks({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'checks', 'run-checks.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runSmokeCli({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'smoke', 'cli-smoke.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + /** * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts */ @@ -315,6 +570,87 @@ function runTauriPreparePublishAssets({ repoRoot, env, args, dryRun }) { }); } +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runTauriValidateUpdaterPubkey({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'tauri', 'validate-updater-pubkey.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runTauriBuildUpdaterArtifacts({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'tauri', 'build-updater-artifacts.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runTauriNotarizeMacosArtifacts({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'tauri', 'notarize-macos-artifacts.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runTauriCollectUpdaterArtifacts({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'tauri', 'collect-updater-artifacts.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runTestingCreateAuthCredentials({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'testing', 'create-auth-credentials.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + return; + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + /** * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts */ @@ -347,6 +683,38 @@ function runGithubPublishRelease({ repoRoot, env, args, dryRun }) { }); } +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runGithubAuditReleaseAssets({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'github', 'audit-release-assets.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + +/** + * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts + */ +function runGithubCommitAndPush({ repoRoot, env, args, dryRun }) { + const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'github', 'commit-and-push.mjs'); + const fullArgs = [scriptPath, ...args]; + if (dryRun) { + console.log(`[pipeline] exec: node ${fullArgs.map((a) => JSON.stringify(a)).join(' ')}`); + } + execFileSync(process.execPath, fullArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + }); +} + /** * @param {{ repoRoot: string; env: Record<string, string>; args: string[]; dryRun: boolean }} opts */ @@ -403,87 +771,257 @@ function runReleaseWrappedScript({ repoRoot, env, scriptFile, args, dryRun, skip }); } +/** + * @param {{ repoRoot: string; env: Record<string, string>; scriptRel: string; args: string[] }} opts + */ +function runJsonScript({ repoRoot, env, scriptRel, args }) { + const out = execFileSync(process.execPath, [path.join(repoRoot, scriptRel), ...args], { + cwd: repoRoot, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, + }).trim(); + + try { + return out ? JSON.parse(out) : {}; + } catch (err) { + throw new Error( + [ + `Expected JSON output from: node ${scriptRel} ${args.map((a) => JSON.stringify(a)).join(' ')}`, + '', + String(err), + '', + 'Raw:', + out, + ].join('\n'), + ); + } +} + function main() { const repoRoot = repoRootFromHere(); - const [subcommandRaw, ...rest] = process.argv.slice(2); + const { argv, style } = parseGlobalCliFlags(process.argv.slice(2)); + const [subcommandRaw, ...rest] = argv; const subcommand = String(subcommandRaw ?? '').trim(); - if (!subcommand) { - fail( - 'Usage: node scripts/pipeline/run.mjs <deploy|npm-publish|npm-release|publish-ui-web|publish-server-runtime|release-bump-plan|release-bump-versions-dev|release-sync-installers|release-bump-version|release-build-cli-binaries|release-build-hstack-binaries|release-build-server-binaries|release-publish-manifests|release-verify-artifacts|release-compute-changed-components|release-resolve-bump-plan|release-compute-deploy-plan|release-build-ui-web-bundle|expo-ota|expo-native-build|expo-download-apk|expo-mobile-meta|expo-submit|expo-publish-apk-release|ui-mobile-release|tauri-prepare-assets|docker-publish|github-publish-release|promote-branch|promote-deploy-branch|release> [args...]', - ); - } - if ( - subcommand !== 'deploy' && - subcommand !== 'npm-publish' && - subcommand !== 'npm-release' && - subcommand !== 'publish-ui-web' && - subcommand !== 'publish-server-runtime' && - subcommand !== 'release-bump-plan' && - subcommand !== 'release-bump-versions-dev' && - subcommand !== 'release-sync-installers' && - subcommand !== 'release-bump-version' && - subcommand !== 'release-build-cli-binaries' && - subcommand !== 'release-build-hstack-binaries' && - subcommand !== 'release-build-server-binaries' && - subcommand !== 'release-publish-manifests' && - subcommand !== 'release-verify-artifacts' && - subcommand !== 'release-compute-changed-components' && - subcommand !== 'release-resolve-bump-plan' && - subcommand !== 'release-compute-deploy-plan' && - subcommand !== 'release-build-ui-web-bundle' && - subcommand !== 'expo-ota' && - subcommand !== 'expo-native-build' && - subcommand !== 'expo-download-apk' && - subcommand !== 'expo-mobile-meta' && - subcommand !== 'expo-submit' && - subcommand !== 'expo-publish-apk-release' && - subcommand !== 'ui-mobile-release' && - subcommand !== 'tauri-prepare-assets' && - subcommand !== 'docker-publish' && - subcommand !== 'github-publish-release' && - subcommand !== 'promote-branch' && - subcommand !== 'promote-deploy-branch' && - subcommand !== 'release' - ) { - fail(`Unsupported subcommand: ${subcommand}`); + const wantsGlobalHelp = subcommand === '--help' || subcommand === '-h' || subcommand === 'help'; + if (wantsGlobalHelp) { + const target = subcommand === 'help' ? String(rest[0] ?? '').trim() : ''; + const out = target ? renderCommandHelp({ style, command: target, cliRelPath: 'scripts/pipeline/run.mjs' }) : renderPipelineHelp({ style, cliRelPath: 'scripts/pipeline/run.mjs' }); + process.stdout.write(out); + process.exit(0); } - if (subcommand === 'deploy') { - const { values } = parseArgs({ - args: rest, - options: { - 'deploy-environment': { type: 'string', default: 'production' }, - component: { type: 'string' }, - repository: { type: 'string', default: '' }, - 'ref-name': { type: 'string', default: '' }, - sha: { type: 'string', default: '' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, - }, - allowPositionals: false, - }); + const wantsCommandHelp = rest.includes('--help') || rest.includes('-h'); + if (subcommand && wantsCommandHelp) { + const out = renderCommandHelp({ style, command: subcommand, cliRelPath: 'scripts/pipeline/run.mjs' }); + process.stdout.write(out); + process.exit(0); + } - const deployEnvironment = String(values['deploy-environment'] ?? '').trim(); - if (!isDeployEnvironment(deployEnvironment)) { - fail(`--deploy-environment must be 'production' or 'preview' (got: ${deployEnvironment})`); - } - const component = String(values.component ?? '').trim(); - if (!isDeployComponent(component)) { - fail(`--component must be 'ui', 'server', 'website', or 'docs' (got: ${component || '<empty>'})`); - } + if (!subcommand) { + fail( + [ + 'Missing command.', + '', + 'Run:', + ' node scripts/pipeline/run.mjs --help', + ].join('\n'), + ); + } - const { env, sources } = loadPipelineEnv({ repoRoot, deployEnvironment }); - const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); - const secretsSource = - secretsSourceRaw === 'auto' || secretsSourceRaw === 'env' || secretsSourceRaw === 'keychain' - ? secretsSourceRaw - : 'auto'; - if (secretsSourceRaw && secretsSource !== secretsSourceRaw) { - fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${secretsSourceRaw})`); + if ( + subcommand !== 'deploy' && + subcommand !== 'npm-publish' && + subcommand !== 'npm-release' && + subcommand !== 'npm-set-preview-versions' && + subcommand !== 'publish-ui-web' && + subcommand !== 'publish-cli-binaries' && + subcommand !== 'publish-hstack-binaries' && + subcommand !== 'publish-server-runtime' && + subcommand !== 'checks-plan' && + subcommand !== 'checks' && + subcommand !== 'smoke-cli' && + subcommand !== 'release-bump-plan' && + subcommand !== 'release-bump-versions-dev' && + subcommand !== 'release-sync-installers' && + subcommand !== 'release-bump-version' && + subcommand !== 'release-build-cli-binaries' && + subcommand !== 'release-build-hstack-binaries' && + subcommand !== 'release-build-server-binaries' && + subcommand !== 'release-publish-manifests' && + subcommand !== 'release-verify-artifacts' && + subcommand !== 'release-compute-changed-components' && + subcommand !== 'release-resolve-bump-plan' && + subcommand !== 'release-compute-deploy-plan' && + subcommand !== 'release-build-ui-web-bundle' && + subcommand !== 'expo-ota' && + subcommand !== 'expo-native-build' && + subcommand !== 'expo-download-apk' && + subcommand !== 'expo-mobile-meta' && + subcommand !== 'expo-submit' && + subcommand !== 'expo-publish-apk-release' && + subcommand !== 'ui-mobile-release' && + subcommand !== 'tauri-prepare-assets' && + subcommand !== 'tauri-validate-updater-pubkey' && + subcommand !== 'tauri-build-updater-artifacts' && + subcommand !== 'tauri-notarize-macos-artifacts' && + subcommand !== 'tauri-collect-updater-artifacts' && + subcommand !== 'testing-create-auth-credentials' && + subcommand !== 'docker-publish' && + subcommand !== 'github-publish-release' && + subcommand !== 'github-audit-release-assets' && + subcommand !== 'github-commit-and-push' && + subcommand !== 'promote-branch' && + subcommand !== 'promote-deploy-branch' && + subcommand !== 'release' + ) { + fail( + [ + `Unsupported subcommand: ${subcommand}`, + '', + 'Run:', + ' node scripts/pipeline/run.mjs --help', + ].join('\n'), + ); + } + + if (subcommand === 'smoke-cli') { + const { values } = parseArgs({ + args: rest, + options: { + 'package-dir': { type: 'string', default: 'apps/cli' }, + 'workspace-name': { type: 'string', default: '@happier-dev/cli' }, + 'skip-build': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const pkgDir = String(values['package-dir'] ?? '').trim() || 'apps/cli'; + const workspaceName = String(values['workspace-name'] ?? '').trim() || '@happier-dev/cli'; + const skipBuild = String(values['skip-build'] ?? '').trim() || 'false'; + const dryRun = values['dry-run'] === true; + + runSmokeCli({ + repoRoot, + env: { ...process.env }, + dryRun, + args: [ + '--package-dir', + pkgDir, + '--workspace-name', + workspaceName, + '--skip-build', + skipBuild, + ...(dryRun ? ['--dry-run'] : []), + ], + }); + return; + } + + if (subcommand === 'checks-plan') { + const { values } = parseArgs({ + args: rest, + options: { + profile: { type: 'string' }, + 'custom-checks': { type: 'string', default: '' }, + 'github-output': { type: 'string', default: '' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const profile = String(values.profile ?? '').trim(); + if (!profile) fail('--profile is required (full|fast|none|custom)'); + const customChecks = String(values['custom-checks'] ?? '').trim(); + const githubOutput = String(values['github-output'] ?? '').trim(); + const dryRun = values['dry-run'] === true; + + runChecksPlan({ + repoRoot, + env: { ...process.env }, + dryRun, + args: [ + '--profile', + profile, + ...(customChecks ? ['--custom-checks', customChecks] : []), + ...(githubOutput ? ['--github-output', githubOutput] : []), + ], + }); + return; + } + + if (subcommand === 'checks') { + const { values } = parseArgs({ + args: rest, + options: { + profile: { type: 'string' }, + 'custom-checks': { type: 'string', default: '' }, + 'install-deps': { type: 'string', default: 'auto' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const profile = String(values.profile ?? '').trim(); + if (!profile) fail('--profile is required (full|fast|none|custom)'); + const customChecks = String(values['custom-checks'] ?? '').trim(); + const installDeps = String(values['install-deps'] ?? '').trim(); + const dryRun = values['dry-run'] === true; + + runChecks({ + repoRoot, + env: { ...process.env, HAPPIER_UI_VENDOR_WEB_ASSETS: process.env.HAPPIER_UI_VENDOR_WEB_ASSETS ?? '0' }, + dryRun, + args: [ + '--profile', + profile, + ...(customChecks ? ['--custom-checks', customChecks] : []), + '--install-deps', + installDeps || 'auto', + ...(dryRun ? ['--dry-run'] : []), + ], + }); + return; + } + + if (subcommand === 'deploy') { + const { values } = parseArgs({ + args: rest, + options: { + 'deploy-environment': { type: 'string', default: 'production' }, + component: { type: 'string' }, + repository: { type: 'string', default: '' }, + 'ref-name': { type: 'string', default: '' }, + sha: { type: 'string', default: '' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); + + const deployEnvironment = String(values['deploy-environment'] ?? '').trim(); + if (!isDeployEnvironment(deployEnvironment)) { + fail(`--deploy-environment must be 'production' or 'preview' (got: ${deployEnvironment})`); + } + const component = String(values.component ?? '').trim(); + if (!isDeployComponent(component)) { + fail(`--component must be 'ui', 'server', 'website', or 'docs' (got: ${component || '<empty>'})`); + } + + const { env, sources } = loadPipelineEnv({ repoRoot, deployEnvironment }); + const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); + const secretsSource = + secretsSourceRaw === 'auto' || secretsSourceRaw === 'env' || secretsSourceRaw === 'keychain' + ? secretsSourceRaw + : 'auto'; + if (secretsSourceRaw && secretsSource !== secretsSourceRaw) { + fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${secretsSourceRaw})`); } const keychainService = String(values['keychain-service'] ?? '').trim() || 'happier/pipeline'; @@ -534,22 +1072,66 @@ function main() { return; } - if (subcommand === 'npm-publish') { + if (subcommand === 'npm-set-preview-versions') { const { values } = parseArgs({ args: rest, options: { - channel: { type: 'string' }, - tag: { type: 'string', default: '' }, - tarball: { type: 'string', default: '' }, - 'tarball-dir': { type: 'string', default: '' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, + 'repo-root': { type: 'string', default: '' }, + 'publish-cli': { type: 'string', default: 'false' }, + 'publish-stack': { type: 'string', default: 'false' }, + 'publish-server': { type: 'string', default: 'false' }, + 'server-runner-dir': { type: 'string', default: 'packages/relay-server' }, + write: { type: 'string', default: 'true' }, }, allowPositionals: false, }); + const repoRootOverride = String(values['repo-root'] ?? '').trim(); + const publishCli = String(values['publish-cli'] ?? '').trim() || 'false'; + const publishStack = String(values['publish-stack'] ?? '').trim() || 'false'; + const publishServer = String(values['publish-server'] ?? '').trim() || 'false'; + const serverRunnerDir = String(values['server-runner-dir'] ?? '').trim() || 'packages/relay-server'; + const write = String(values.write ?? '').trim() || 'true'; + + runNpmSetPreviewVersions({ + repoRoot, + env: { ...process.env }, + dryRun: false, + args: [ + ...(repoRootOverride ? ['--repo-root', repoRootOverride] : []), + '--publish-cli', + publishCli, + '--publish-stack', + publishStack, + '--publish-server', + publishServer, + '--server-runner-dir', + serverRunnerDir, + '--write', + write, + ], + }); + + return; + } + + if (subcommand === 'npm-publish') { + const { values } = parseArgs({ + args: rest, + options: { + channel: { type: 'string' }, + tag: { type: 'string', default: '' }, + tarball: { type: 'string', default: '' }, + 'tarball-dir': { type: 'string', default: '' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); + const channel = String(values.channel ?? '').trim(); if (!isDeployEnvironment(channel)) { fail(`--channel must be 'production' or 'preview' (got: ${channel || '<empty>'})`); @@ -581,12 +1163,14 @@ function main() { console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); } - const tarball = String(values.tarball ?? '').trim(); - const tarballDir = String(values['tarball-dir'] ?? '').trim(); - const tag = String(values.tag ?? '').trim(); - const dryRun = values['dry-run'] === true; + const tarball = String(values.tarball ?? '').trim(); + const tarballDir = String(values['tarball-dir'] ?? '').trim(); + const tag = String(values.tag ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); - console.log(`[pipeline] npm publish: channel=${channel}`); + console.log(`[pipeline] npm publish: channel=${channel}`); runNpmPublishTarball({ repoRoot, @@ -605,22 +1189,23 @@ function main() { return; } - if (subcommand === 'npm-release') { - const { values } = parseArgs({ - args: rest, - options: { - channel: { type: 'string' }, - 'publish-cli': { type: 'string', default: 'false' }, - 'publish-stack': { type: 'string', default: 'false' }, - 'publish-server': { type: 'string', default: 'false' }, - 'server-runner-dir': { type: 'string', default: 'packages/relay-server' }, - 'run-tests': { type: 'string', default: 'true' }, - mode: { type: 'string', default: 'pack+publish' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, - }, + if (subcommand === 'npm-release') { + const { values } = parseArgs({ + args: rest, + options: { + channel: { type: 'string' }, + 'publish-cli': { type: 'string', default: 'false' }, + 'publish-stack': { type: 'string', default: 'false' }, + 'publish-server': { type: 'string', default: 'false' }, + 'server-runner-dir': { type: 'string', default: 'packages/relay-server' }, + 'run-tests': { type: 'string', default: 'auto' }, + mode: { type: 'string', default: 'pack+publish' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, allowPositionals: false, }); @@ -658,12 +1243,14 @@ function main() { const publishCli = String(values['publish-cli'] ?? '').trim(); const publishStack = String(values['publish-stack'] ?? '').trim(); const publishServer = String(values['publish-server'] ?? '').trim(); - const runnerDir = String(values['server-runner-dir'] ?? '').trim(); - const runTests = String(values['run-tests'] ?? '').trim(); - const mode = String(values.mode ?? '').trim(); - const dryRun = values['dry-run'] === true; + const runnerDir = String(values['server-runner-dir'] ?? '').trim(); + const runTests = String(values['run-tests'] ?? '').trim(); + const mode = String(values.mode ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); - console.log(`[pipeline] npm release: channel=${channel}`); + console.log(`[pipeline] npm release: channel=${channel}`); runNpmReleasePackages({ repoRoot, @@ -685,20 +1272,21 @@ function main() { return; } - if (subcommand === 'publish-ui-web') { - const { values } = parseArgs({ - args: rest, - options: { - channel: { type: 'string' }, - 'allow-stable': { type: 'string', default: 'false' }, - 'release-message': { type: 'string', default: '' }, - 'run-contracts': { type: 'string', default: 'true' }, - 'check-installers': { type: 'string', default: 'true' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, - }, + if (subcommand === 'publish-ui-web') { + const { values } = parseArgs({ + args: rest, + options: { + channel: { type: 'string' }, + 'allow-stable': { type: 'string', default: 'false' }, + 'release-message': { type: 'string', default: '' }, + 'run-contracts': { type: 'string', default: 'auto' }, + 'check-installers': { type: 'string', default: 'true' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, allowPositionals: false, }); @@ -733,15 +1321,17 @@ function main() { console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); } - const allowStable = String(values['allow-stable'] ?? '').trim(); - const releaseMessage = String(values['release-message'] ?? '').trim(); - const runContracts = String(values['run-contracts'] ?? '').trim(); - const checkInstallers = String(values['check-installers'] ?? '').trim(); - const dryRun = values['dry-run'] === true; + const allowStable = String(values['allow-stable'] ?? '').trim(); + const releaseMessage = String(values['release-message'] ?? '').trim(); + const runContracts = String(values['run-contracts'] ?? '').trim(); + const checkInstallers = String(values['check-installers'] ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); - runPublishUiWeb({ - repoRoot, - env: mergedEnv, + runPublishUiWeb({ + repoRoot, + env: mergedEnv, dryRun, args: [ '--channel', @@ -751,30 +1341,189 @@ function main() { '--release-message', releaseMessage, '--run-contracts', - runContracts || 'true', + runContracts || 'auto', '--check-installers', checkInstallers || 'true', ...(dryRun ? ['--dry-run'] : []), ], }); - return; - } + return; + } - if (subcommand === 'publish-server-runtime') { - const { values } = parseArgs({ - args: rest, - options: { - channel: { type: 'string' }, - 'allow-stable': { type: 'string', default: 'false' }, - 'release-message': { type: 'string', default: '' }, - 'run-contracts': { type: 'string', default: 'true' }, - 'check-installers': { type: 'string', default: 'true' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, - }, + if (subcommand === 'publish-cli-binaries') { + const { values } = parseArgs({ + args: rest, + options: { + channel: { type: 'string' }, + 'allow-stable': { type: 'string', default: 'false' }, + 'release-message': { type: 'string', default: '' }, + 'run-contracts': { type: 'string', default: 'auto' }, + 'check-installers': { type: 'string', default: 'true' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); + + const channel = String(values.channel ?? '').trim(); + if (!isRollingReleaseChannel(channel)) { + fail(`--channel must be 'stable' or 'preview' (got: ${channel || '<empty>'})`); + } + + const { env, sources } = loadPipelineEnv({ repoRoot }); + const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); + const secretsSource = + secretsSourceRaw === 'auto' || secretsSourceRaw === 'env' || secretsSourceRaw === 'keychain' + ? secretsSourceRaw + : 'auto'; + if (secretsSourceRaw && secretsSource !== secretsSourceRaw) { + fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${secretsSourceRaw})`); + } + + const keychainService = String(values['keychain-service'] ?? '').trim() || 'happier/pipeline'; + const keychainAccount = String(values['keychain-account'] ?? '').trim() || undefined; + const { env: mergedEnv, usedKeychain } = loadSecrets({ + baseEnv: env, + secretsSource, + keychainService, + keychainAccount, + }); + if (sources.length > 0) { + console.log(`[pipeline] using env sources: ${sources.join(', ')}`); + console.log('[pipeline] warning: env-file mode is for fast local iteration; prefer Keychain bundle for long-term use.'); + } + if (usedKeychain) { + console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); + } + + const allowStable = String(values['allow-stable'] ?? '').trim(); + const releaseMessage = String(values['release-message'] ?? '').trim(); + const runContracts = String(values['run-contracts'] ?? '').trim(); + const checkInstallers = String(values['check-installers'] ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); + + runPublishCliBinaries({ + repoRoot, + env: mergedEnv, + dryRun, + args: [ + '--channel', + channel, + '--allow-stable', + allowStable || 'false', + '--release-message', + releaseMessage, + '--run-contracts', + runContracts || 'auto', + '--check-installers', + checkInstallers || 'true', + ...(dryRun ? ['--dry-run'] : []), + ], + }); + + return; + } + + if (subcommand === 'publish-hstack-binaries') { + const { values } = parseArgs({ + args: rest, + options: { + channel: { type: 'string' }, + 'allow-stable': { type: 'string', default: 'false' }, + 'release-message': { type: 'string', default: '' }, + 'run-contracts': { type: 'string', default: 'auto' }, + 'check-installers': { type: 'string', default: 'true' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); + + const channel = String(values.channel ?? '').trim(); + if (!isRollingReleaseChannel(channel)) { + fail(`--channel must be 'stable' or 'preview' (got: ${channel || '<empty>'})`); + } + + const { env, sources } = loadPipelineEnv({ repoRoot }); + const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); + const secretsSource = + secretsSourceRaw === 'auto' || secretsSourceRaw === 'env' || secretsSourceRaw === 'keychain' + ? secretsSourceRaw + : 'auto'; + if (secretsSourceRaw && secretsSource !== secretsSourceRaw) { + fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${secretsSourceRaw})`); + } + + const keychainService = String(values['keychain-service'] ?? '').trim() || 'happier/pipeline'; + const keychainAccount = String(values['keychain-account'] ?? '').trim() || undefined; + const { env: mergedEnv, usedKeychain } = loadSecrets({ + baseEnv: env, + secretsSource, + keychainService, + keychainAccount, + }); + if (sources.length > 0) { + console.log(`[pipeline] using env sources: ${sources.join(', ')}`); + console.log('[pipeline] warning: env-file mode is for fast local iteration; prefer Keychain bundle for long-term use.'); + } + if (usedKeychain) { + console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); + } + + const allowStable = String(values['allow-stable'] ?? '').trim(); + const releaseMessage = String(values['release-message'] ?? '').trim(); + const runContracts = String(values['run-contracts'] ?? '').trim(); + const checkInstallers = String(values['check-installers'] ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); + + runPublishHstackBinaries({ + repoRoot, + env: mergedEnv, + dryRun, + args: [ + '--channel', + channel, + '--allow-stable', + allowStable || 'false', + '--release-message', + releaseMessage, + '--run-contracts', + runContracts || 'auto', + '--check-installers', + checkInstallers || 'true', + ...(dryRun ? ['--dry-run'] : []), + ], + }); + + return; + } + + if (subcommand === 'publish-server-runtime') { + const { values } = parseArgs({ + args: rest, + options: { + channel: { type: 'string' }, + 'allow-stable': { type: 'string', default: 'false' }, + 'release-message': { type: 'string', default: '' }, + 'run-contracts': { type: 'string', default: 'auto' }, + 'check-installers': { type: 'string', default: 'true' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, allowPositionals: false, }); @@ -809,15 +1558,17 @@ function main() { console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); } - const allowStable = String(values['allow-stable'] ?? '').trim(); - const releaseMessage = String(values['release-message'] ?? '').trim(); - const runContracts = String(values['run-contracts'] ?? '').trim(); - const checkInstallers = String(values['check-installers'] ?? '').trim(); - const dryRun = values['dry-run'] === true; + const allowStable = String(values['allow-stable'] ?? '').trim(); + const releaseMessage = String(values['release-message'] ?? '').trim(); + const runContracts = String(values['run-contracts'] ?? '').trim(); + const checkInstallers = String(values['check-installers'] ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); - runPublishServerRuntime({ - repoRoot, - env: mergedEnv, + runPublishServerRuntime({ + repoRoot, + env: mergedEnv, dryRun, args: [ '--channel', @@ -827,7 +1578,7 @@ function main() { '--release-message', releaseMessage, '--run-contracts', - runContracts || 'true', + runContracts || 'auto', '--check-installers', checkInstallers || 'true', ...(dryRun ? ['--dry-run'] : []), @@ -938,53 +1689,32 @@ function main() { return; } - if ( - subcommand === 'release-sync-installers' || - subcommand === 'release-bump-version' || - subcommand === 'release-build-cli-binaries' || - subcommand === 'release-build-hstack-binaries' || - subcommand === 'release-build-server-binaries' || - subcommand === 'release-publish-manifests' || - subcommand === 'release-verify-artifacts' || - subcommand === 'release-compute-changed-components' || - subcommand === 'release-resolve-bump-plan' || - subcommand === 'release-compute-deploy-plan' || - subcommand === 'release-build-ui-web-bundle' - ) { - const { values, positionals } = parseArgs({ - args: rest, - options: { - 'deploy-environment': { type: 'string', default: 'production' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, - }, - allowPositionals: true, - strict: false, - }); - - const deployEnvironment = String(values['deploy-environment'] ?? '').trim() || 'production'; - if (!isDeployEnvironment(deployEnvironment)) { - fail(`--deploy-environment must be 'production' or 'preview' (got: ${deployEnvironment || '<empty>'})`); - } - - const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); - const secretsSource = - secretsSourceRaw === 'auto' || secretsSourceRaw === 'env' || secretsSourceRaw === 'keychain' - ? secretsSourceRaw - : 'auto'; - if (secretsSourceRaw && secretsSource !== secretsSourceRaw) { - fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${secretsSourceRaw})`); - } - - const keychainService = String(values['keychain-service'] ?? '').trim() || 'happier/pipeline'; - const keychainAccount = String(values['keychain-account'] ?? '').trim() || undefined; - const dryRun = values['dry-run'] === true; - - const scriptFile = - subcommand === 'release-sync-installers' - ? 'sync-installers.mjs' + if ( + subcommand === 'release-sync-installers' || + subcommand === 'release-bump-version' || + subcommand === 'release-build-cli-binaries' || + subcommand === 'release-build-hstack-binaries' || + subcommand === 'release-build-server-binaries' || + subcommand === 'release-publish-manifests' || + subcommand === 'release-verify-artifacts' || + subcommand === 'release-compute-changed-components' || + subcommand === 'release-resolve-bump-plan' || + subcommand === 'release-compute-deploy-plan' || + subcommand === 'release-build-ui-web-bundle' + ) { + const { + deployEnvironment, + dryRun, + secretsSource, + keychainService, + keychainAccount: keychainAccountRaw, + passthrough, + } = splitWrappedReleaseArgs(rest); + const keychainAccount = keychainAccountRaw.trim() || undefined; + + const scriptFile = + subcommand === 'release-sync-installers' + ? 'sync-installers.mjs' : subcommand === 'release-bump-version' ? 'bump-version.mjs' : subcommand === 'release-build-cli-binaries' @@ -1005,17 +1735,17 @@ function main() { ? 'compute-deploy-plan.mjs' : 'build-ui-web-bundle.mjs'; - if (dryRun) { - runReleaseWrappedScript({ - repoRoot, - env: process.env, - scriptFile, - args: positionals, - dryRun: true, - skipExecOnDryRun: true, - }); - return; - } + if (dryRun) { + runReleaseWrappedScript({ + repoRoot, + env: process.env, + scriptFile, + args: passthrough, + dryRun: true, + skipExecOnDryRun: true, + }); + return; + } const { env, sources } = loadPipelineEnv({ repoRoot, deployEnvironment }); const { env: mergedEnv, usedKeychain } = loadSecrets({ @@ -1032,15 +1762,15 @@ function main() { console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); } - runReleaseWrappedScript({ - repoRoot, - env: mergedEnv, - scriptFile, - args: positionals, - dryRun: false, - }); - return; - } + runReleaseWrappedScript({ + repoRoot, + env: mergedEnv, + scriptFile, + args: passthrough, + dryRun: false, + }); + return; + } if (subcommand === 'expo-ota') { const { values } = parseArgs({ @@ -1522,14 +2252,22 @@ function main() { fail(`--platform must be 'ios', 'android', or 'all' (got: ${platform})`); } - const profile = String(values.profile ?? '').trim(); - if ((action === 'native' || action === 'native_submit') && !profile) { - fail('--profile is required for native actions'); - } - const publishApkReleaseMode = String(values['publish-apk-release'] ?? '').trim().toLowerCase() || 'auto'; - if (publishApkReleaseMode !== 'auto' && publishApkReleaseMode !== 'true' && publishApkReleaseMode !== 'false') { - fail(`--publish-apk-release must be 'auto', 'true', or 'false' (got: ${values['publish-apk-release']})`); - } + const profile = String(values.profile ?? '').trim(); + if ((action === 'native' || action === 'native_submit') && !profile) { + fail('--profile is required for native actions'); + } + if (action === 'native' || action === 'native_submit') { + const expectedPrefix = environment === 'production' ? 'production' : 'preview'; + if (!profile.startsWith(expectedPrefix)) { + fail( + `--profile must start with '${expectedPrefix}' for --environment '${environment}' (got: ${profile || '<empty>'}).`, + ); + } + } + const publishApkReleaseMode = String(values['publish-apk-release'] ?? '').trim().toLowerCase() || 'auto'; + if (publishApkReleaseMode !== 'auto' && publishApkReleaseMode !== 'true' && publishApkReleaseMode !== 'false') { + fail(`--publish-apk-release must be 'auto', 'true', or 'false' (got: ${values['publish-apk-release']})`); + } const buildJson = String(values['build-json'] ?? '').trim() || '/tmp/eas_build.json'; const outDir = String(values['out-dir'] ?? '').trim() || 'dist/ui-mobile'; @@ -1793,14 +2531,41 @@ function main() { } } + return; + } + + if (subcommand === 'tauri-validate-updater-pubkey') { + const { values } = parseArgs({ + args: rest, + options: { + 'config-path': { type: 'string', default: '' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const configPath = String(values['config-path'] ?? '').trim(); + if (!configPath) fail('--config-path is required'); + const dryRun = values['dry-run'] === true; + + runTauriValidateUpdaterPubkey({ + repoRoot, + env: { ...process.env }, + dryRun, + args: [ + '--config-path', + configPath, + ], + }); + return; } - if (subcommand === 'tauri-prepare-assets') { - const { values } = parseArgs({ - args: rest, - options: { - environment: { type: 'string' }, + if (subcommand === 'tauri-prepare-assets') { + const { values } = parseArgs({ + args: rest, + options: { + environment: { type: 'string' }, repo: { type: 'string' }, 'ui-version': { type: 'string' }, 'artifacts-dir': { type: 'string', default: 'dist/tauri/updates' }, @@ -1869,23 +2634,231 @@ function main() { ], }); - return; - } + return; + } + + if (subcommand === 'tauri-build-updater-artifacts') { + const { values } = parseArgs({ + args: rest, + options: { + environment: { type: 'string' }, + 'build-version': { type: 'string', default: '' }, + 'tauri-target': { type: 'string', default: '' }, + 'ui-dir': { type: 'string', default: 'apps/ui' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); + + const environment = String(values.environment ?? '').trim(); + if (environment !== 'preview' && environment !== 'production') { + fail(`--environment must be 'preview' or 'production' (got: ${environment || '<empty>'})`); + } + + const buildVersion = String(values['build-version'] ?? '').trim(); + const tauriTarget = String(values['tauri-target'] ?? '').trim(); + const uiDir = String(values['ui-dir'] ?? '').trim() || 'apps/ui'; + const dryRun = values['dry-run'] === true; + + const { env, sources } = loadPipelineEnv({ repoRoot }); + const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); + const secretsSource = + secretsSourceRaw === 'auto' || secretsSourceRaw === 'env' || secretsSourceRaw === 'keychain' + ? secretsSourceRaw + : 'auto'; + if (secretsSourceRaw && secretsSource !== secretsSourceRaw) { + fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${secretsSourceRaw})`); + } + + const keychainService = String(values['keychain-service'] ?? '').trim() || 'happier/pipeline'; + const keychainAccount = String(values['keychain-account'] ?? '').trim() || undefined; + const { env: mergedEnv, usedKeychain } = loadSecrets({ + baseEnv: env, + secretsSource, + keychainService, + keychainAccount, + }); + if (sources.length > 0) { + console.log(`[pipeline] using env sources: ${sources.join(', ')}`); + console.log('[pipeline] warning: env-file mode is for fast local iteration; prefer Keychain bundle for long-term use.'); + } + if (usedKeychain) { + console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); + } + + runTauriBuildUpdaterArtifacts({ + repoRoot, + env: mergedEnv, + dryRun, + args: [ + '--environment', + environment, + ...(buildVersion ? ['--build-version', buildVersion] : []), + ...(tauriTarget ? ['--tauri-target', tauriTarget] : []), + ...(uiDir ? ['--ui-dir', uiDir] : []), + ...(dryRun ? ['--dry-run'] : []), + ], + }); + + return; + } + + if (subcommand === 'tauri-notarize-macos-artifacts') { + const { values } = parseArgs({ + args: rest, + options: { + 'ui-dir': { type: 'string', default: 'apps/ui' }, + 'tauri-target': { type: 'string', default: '' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); + + const uiDir = String(values['ui-dir'] ?? '').trim() || 'apps/ui'; + const tauriTarget = String(values['tauri-target'] ?? '').trim(); + const dryRun = values['dry-run'] === true; + + const { env, sources } = loadPipelineEnv({ repoRoot }); + const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); + const secretsSource = + secretsSourceRaw === 'auto' || secretsSourceRaw === 'env' || secretsSourceRaw === 'keychain' + ? secretsSourceRaw + : 'auto'; + if (secretsSourceRaw && secretsSource !== secretsSourceRaw) { + fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${secretsSourceRaw})`); + } + + const keychainService = String(values['keychain-service'] ?? '').trim() || 'happier/pipeline'; + const keychainAccount = String(values['keychain-account'] ?? '').trim() || undefined; + const { env: mergedEnv, usedKeychain } = loadSecrets({ + baseEnv: env, + secretsSource, + keychainService, + keychainAccount, + }); + if (sources.length > 0) { + console.log(`[pipeline] using env sources: ${sources.join(', ')}`); + console.log('[pipeline] warning: env-file mode is for fast local iteration; prefer Keychain bundle for long-term use.'); + } + if (usedKeychain) { + console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); + } + + runTauriNotarizeMacosArtifacts({ + repoRoot, + env: mergedEnv, + dryRun, + args: [ + ...(uiDir ? ['--ui-dir', uiDir] : []), + ...(tauriTarget ? ['--tauri-target', tauriTarget] : []), + ...(dryRun ? ['--dry-run'] : []), + ], + }); + + return; + } + + if (subcommand === 'tauri-collect-updater-artifacts') { + const { values } = parseArgs({ + args: rest, + options: { + environment: { type: 'string' }, + 'platform-key': { type: 'string' }, + 'ui-version': { type: 'string' }, + 'tauri-target': { type: 'string', default: '' }, + 'ui-dir': { type: 'string', default: 'apps/ui' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const environment = String(values.environment ?? '').trim(); + if (environment !== 'preview' && environment !== 'production') { + fail(`--environment must be 'preview' or 'production' (got: ${environment || '<empty>'})`); + } + + const platformKey = String(values['platform-key'] ?? '').trim(); + const uiVersion = String(values['ui-version'] ?? '').trim(); + const tauriTarget = String(values['tauri-target'] ?? '').trim(); + const uiDir = String(values['ui-dir'] ?? '').trim() || 'apps/ui'; + const dryRun = values['dry-run'] === true; + + runTauriCollectUpdaterArtifacts({ + repoRoot, + env: { ...process.env }, + dryRun, + args: [ + '--environment', + environment, + '--platform-key', + platformKey, + '--ui-version', + uiVersion, + ...(tauriTarget ? ['--tauri-target', tauriTarget] : []), + ...(uiDir ? ['--ui-dir', uiDir] : []), + ...(dryRun ? ['--dry-run'] : []), + ], + }); + + return; + } + + if (subcommand === 'testing-create-auth-credentials') { + const { values } = parseArgs({ + args: rest, + options: { + 'server-url': { type: 'string', default: '' }, + 'home-dir': { type: 'string', default: '' }, + 'active-server-id': { type: 'string', default: '' }, + 'secret-base64': { type: 'string', default: '' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const serverUrl = String(values['server-url'] ?? '').trim(); + const homeDir = String(values['home-dir'] ?? '').trim(); + const activeServerId = String(values['active-server-id'] ?? '').trim(); + const secretBase64 = String(values['secret-base64'] ?? '').trim(); + const dryRun = values['dry-run'] === true; - if (subcommand === 'docker-publish') { - const { values } = parseArgs({ - args: rest, - options: { - channel: { type: 'string' }, - sha: { type: 'string', default: '' }, - 'push-latest': { type: 'string', default: 'true' }, - 'build-relay': { type: 'string', default: 'true' }, - 'build-devcontainer': { type: 'string', default: 'true' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, - }, + runTestingCreateAuthCredentials({ + repoRoot, + env: { ...process.env }, + dryRun, + args: [ + ...(serverUrl ? ['--server-url', serverUrl] : []), + ...(homeDir ? ['--home-dir', homeDir] : []), + ...(activeServerId ? ['--active-server-id', activeServerId] : []), + ...(secretBase64 ? ['--secret-base64', secretBase64] : []), + ], + }); + + return; + } + + if (subcommand === 'docker-publish') { + const { values } = parseArgs({ + args: rest, + options: { + channel: { type: 'string' }, + registries: { type: 'string', default: '' }, + sha: { type: 'string', default: '' }, + 'push-latest': { type: 'string', default: 'true' }, + 'build-relay': { type: 'string', default: 'true' }, + 'build-dev-box': { type: 'string', default: 'true' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, allowPositionals: false, }); @@ -1921,12 +2894,15 @@ function main() { } const sha = String(values.sha ?? '').trim(); - const pushLatest = String(values['push-latest'] ?? '').trim(); - const buildRelay = String(values['build-relay'] ?? '').trim(); - const buildDevcontainer = String(values['build-devcontainer'] ?? '').trim(); - const dryRun = values['dry-run'] === true; + const registries = String(values.registries ?? '').trim(); + const pushLatest = String(values['push-latest'] ?? '').trim(); + const buildRelay = String(values['build-relay'] ?? '').trim(); + const buildDevBox = String(values['build-dev-box'] ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); - console.log(`[pipeline] docker publish: channel=${channel}`); + console.log(`[pipeline] docker publish: channel=${channel}`); runDockerPublishImages({ repoRoot, @@ -1935,38 +2911,135 @@ function main() { args: [ '--channel', channel, + ...(registries ? ['--registries', registries] : []), ...(sha ? ['--sha', sha] : []), ...(pushLatest ? ['--push-latest', pushLatest] : []), ...(buildRelay ? ['--build-relay', buildRelay] : []), - ...(buildDevcontainer ? ['--build-devcontainer', buildDevcontainer] : []), + ...(buildDevBox ? ['--build-dev-box', buildDevBox] : []), ...(dryRun ? ['--dry-run'] : []), ], }); - return; - } + return; + } - if (subcommand === 'github-publish-release') { - const { values } = parseArgs({ - args: rest, - options: { - tag: { type: 'string' }, - title: { type: 'string' }, - 'target-sha': { type: 'string' }, - prerelease: { type: 'string' }, - 'rolling-tag': { type: 'string' }, - 'generate-notes': { type: 'string' }, - notes: { type: 'string', default: '' }, - assets: { type: 'string', default: '' }, - 'assets-dir': { type: 'string', default: '' }, - clobber: { type: 'string', default: 'true' }, - 'prune-assets': { type: 'string', default: 'false' }, - 'release-message': { type: 'string', default: '' }, - 'dry-run': { type: 'boolean', default: false }, - 'max-commits': { type: 'string', default: '200' }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, + if (subcommand === 'github-audit-release-assets') { + const { values } = parseArgs({ + args: rest, + options: { + tag: { type: 'string' }, + kind: { type: 'string' }, + version: { type: 'string', default: '' }, + targets: { type: 'string', default: '' }, + repo: { type: 'string', default: '' }, + 'assets-json': { type: 'string', default: '' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const tag = String(values.tag ?? '').trim(); + const kind = String(values.kind ?? '').trim(); + if (!tag) fail('--tag is required'); + if (!kind) fail('--kind is required'); + + const version = String(values.version ?? '').trim(); + const targets = String(values.targets ?? '').trim(); + const repo = String(values.repo ?? '').trim(); + const assetsJson = String(values['assets-json'] ?? '').trim(); + const dryRun = values['dry-run'] === true; + + runGithubAuditReleaseAssets({ + repoRoot, + env: { ...process.env }, + dryRun, + args: [ + '--tag', + tag, + '--kind', + kind, + ...(version ? ['--version', version] : []), + ...(targets ? ['--targets', targets] : []), + ...(repo ? ['--repo', repo] : []), + ...(assetsJson ? ['--assets-json', assetsJson] : []), + ], + }); + + return; + } + + if (subcommand === 'github-commit-and-push') { + const { values } = parseArgs({ + args: rest, + options: { + paths: { type: 'string', default: '' }, + 'allow-missing': { type: 'string', default: 'false' }, + message: { type: 'string', default: '' }, + 'author-name': { type: 'string', default: '' }, + 'author-email': { type: 'string', default: '' }, + remote: { type: 'string', default: '' }, + 'push-ref': { type: 'string', default: '' }, + 'push-mode': { type: 'string', default: '' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const paths = String(values.paths ?? '').trim(); + const allowMissing = String(values['allow-missing'] ?? '').trim() || 'false'; + const message = String(values.message ?? '').trim(); + const authorName = String(values['author-name'] ?? '').trim(); + const authorEmail = String(values['author-email'] ?? '').trim(); + const remote = String(values.remote ?? '').trim(); + const pushRef = String(values['push-ref'] ?? '').trim(); + const pushMode = String(values['push-mode'] ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); + + runGithubCommitAndPush({ + repoRoot, + env: { ...process.env }, + dryRun, + args: [ + ...(paths ? ['--paths', paths] : []), + ...(allowMissing ? ['--allow-missing', allowMissing] : []), + ...(message ? ['--message', message] : []), + ...(authorName ? ['--author-name', authorName] : []), + ...(authorEmail ? ['--author-email', authorEmail] : []), + ...(remote ? ['--remote', remote] : []), + ...(pushRef ? ['--push-ref', pushRef] : []), + ...(pushMode ? ['--push-mode', pushMode] : []), + ...(dryRun ? ['--dry-run'] : []), + ], + }); + + return; + } + + if (subcommand === 'github-publish-release') { + const { values } = parseArgs({ + args: rest, + options: { + tag: { type: 'string' }, + title: { type: 'string' }, + 'target-sha': { type: 'string' }, + prerelease: { type: 'string' }, + 'rolling-tag': { type: 'string' }, + 'generate-notes': { type: 'string' }, + notes: { type: 'string', default: '' }, + assets: { type: 'string', default: '' }, + 'assets-dir': { type: 'string', default: '' }, + clobber: { type: 'string', default: 'true' }, + 'prune-assets': { type: 'string', default: 'false' }, + 'release-message': { type: 'string', default: '' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'max-commits': { type: 'string', default: '200' }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, }, allowPositionals: false, }); @@ -2000,12 +3073,14 @@ function main() { console.log(`[pipeline] using env sources: ${sources.join(', ')}`); console.log('[pipeline] warning: env-file mode is for fast local iteration; prefer Keychain bundle for long-term use.'); } - if (usedKeychain) { - console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); - } + if (usedKeychain) { + console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); + } - const dryRun = values['dry-run'] === true; - console.log(`[pipeline] github release: tag=${tag}`); + const dryRun = values['dry-run'] === true; + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); + console.log(`[pipeline] github release: tag=${tag}`); runGithubPublishRelease({ repoRoot, @@ -2045,33 +3120,38 @@ function main() { return; } - if (subcommand === 'promote-branch') { - const { values } = parseArgs({ - args: rest, - options: { - source: { type: 'string' }, - target: { type: 'string' }, - mode: { type: 'string' }, - confirm: { type: 'string', default: '' }, - 'allow-reset': { type: 'string', default: 'false' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, - }, + if (subcommand === 'promote-branch') { + const { values } = parseArgs({ + args: rest, + options: { + source: { type: 'string' }, + target: { type: 'string' }, + mode: { type: 'string' }, + confirm: { type: 'string', default: '' }, + 'allow-reset': { type: 'string', default: 'false' }, + 'summary-file': { type: 'string', default: '' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, allowPositionals: false, }); const source = String(values.source ?? '').trim(); const target = String(values.target ?? '').trim(); - const mode = String(values.mode ?? '').trim(); - const confirm = String(values.confirm ?? '').trim(); - const allowReset = String(values['allow-reset'] ?? '').trim(); - const dryRun = values['dry-run'] === true; + const mode = String(values.mode ?? '').trim(); + const confirm = String(values.confirm ?? '').trim(); + const allowReset = String(values['allow-reset'] ?? '').trim(); + const summaryFile = String(values['summary-file'] ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); - if (!source || !target || !mode) { - fail('--source, --target, and --mode are required'); - } + if (!source || !target || !mode) { + fail('--source, --target, and --mode are required'); + } const { env, sources } = loadPipelineEnv({ repoRoot }); const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); @@ -2116,6 +3196,7 @@ function main() { allowReset || 'false', '--confirm', confirm, + ...(summaryFile ? ['--summary-file', summaryFile] : []), ...(dryRun ? ['--dry-run'] : []), ], }); @@ -2123,21 +3204,23 @@ function main() { return; } - if (subcommand === 'promote-deploy-branch') { - const { values } = parseArgs({ - args: rest, - options: { - 'deploy-environment': { type: 'string' }, - component: { type: 'string' }, - 'source-ref': { type: 'string', default: '' }, - sha: { type: 'string', default: '' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, - }, - allowPositionals: false, - }); + if (subcommand === 'promote-deploy-branch') { + const { values } = parseArgs({ + args: rest, + options: { + 'deploy-environment': { type: 'string' }, + component: { type: 'string' }, + 'source-ref': { type: 'string', default: '' }, + sha: { type: 'string', default: '' }, + 'summary-file': { type: 'string', default: '' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); const deployEnvironment = String(values['deploy-environment'] ?? '').trim(); if (!isDeployEnvironment(deployEnvironment)) { @@ -2174,329 +3257,682 @@ function main() { console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); } - const sourceRef = String(values['source-ref'] ?? '').trim(); - const sha = String(values.sha ?? '').trim(); - const dryRun = values['dry-run'] === true; + const sourceRef = String(values['source-ref'] ?? '').trim(); + const sha = String(values.sha ?? '').trim(); + const summaryFile = String(values['summary-file'] ?? '').trim(); + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + const dryRun = values['dry-run'] === true; + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); - const deployBranch = `deploy/${deployEnvironment}/${component}`; - console.log(`[pipeline] promote deploy branch: ${deployBranch} <= ${sourceRef || sha}`); + const deployBranch = `deploy/${deployEnvironment}/${component}`; + console.log(`[pipeline] promote deploy branch: ${deployBranch} <= ${sourceRef || sha}`); - runGithubPromoteDeployBranch({ - repoRoot, - env: mergedEnv, - dryRun, - args: [ - '--deploy-environment', - deployEnvironment, - '--component', - component, - ...(sourceRef ? ['--source-ref', sourceRef] : []), - ...(sha ? ['--sha', sha] : []), - ...(dryRun ? ['--dry-run'] : []), - ], - }); + runGithubPromoteDeployBranch({ + repoRoot, + env: mergedEnv, + dryRun, + args: [ + '--deploy-environment', + deployEnvironment, + '--component', + component, + ...(sourceRef ? ['--source-ref', sourceRef] : []), + ...(sha ? ['--sha', sha] : []), + ...(summaryFile ? ['--summary-file', summaryFile] : []), + ...(dryRun ? ['--dry-run'] : []), + ], + }); return; } - if (subcommand === 'release') { - const { values } = parseArgs({ - args: rest, - options: { - confirm: { type: 'string' }, - repository: { type: 'string' }, - 'deploy-environment': { type: 'string', default: 'production' }, - 'deploy-targets': { type: 'string', default: 'ui,server,website,docs' }, - 'publish-docker': { type: 'string', default: 'auto' }, - 'publish-ui-web': { type: 'string', default: 'auto' }, - 'publish-server-runtime': { type: 'string', default: 'auto' }, - 'release-message': { type: 'string', default: '' }, - 'npm-targets': { type: 'string', default: '' }, - 'npm-mode': { type: 'string', default: 'pack+publish' }, - 'npm-run-tests': { type: 'string', default: 'true' }, - 'npm-server-runner-dir': { type: 'string', default: 'packages/relay-server' }, - 'dry-run': { type: 'boolean', default: false }, - 'secrets-source': { type: 'string', default: 'auto' }, - 'keychain-service': { type: 'string', default: 'happier/pipeline' }, - 'keychain-account': { type: 'string', default: '' }, - }, - allowPositionals: false, - }); - - const action = String(values.confirm ?? '').trim(); - if (!action) { - fail('--confirm is required (e.g. "release preview from dev")'); - } - if (action !== 'release preview from dev' && action !== 'release dev to main' && action !== 'reset main from dev') { - fail(`Unsupported --confirm action: ${action}`); - } - - const repository = String(values.repository ?? '').trim(); - if (!repository) { - fail('--repository is required (e.g. happier-dev/happier)'); - } - - const deployEnvironment = String(values['deploy-environment'] ?? '').trim(); - if (!isDeployEnvironment(deployEnvironment)) { - fail(`--deploy-environment must be 'production' or 'preview' (got: ${deployEnvironment || '<empty>'})`); - } - - const deployTargets = parseCsvList(String(values['deploy-targets'] ?? '')); - if (deployTargets.length === 0) { - fail('--deploy-targets must not be empty'); - } - for (const t of deployTargets) { - if (!isReleaseTarget(t)) { - fail(`--deploy-targets contains unsupported target '${t}' (supported: ui,server,website,docs,cli,stack,server_runner)`); - } - } - - const dryRun = values['dry-run'] === true; - - const { env, sources } = loadPipelineEnv({ repoRoot, deployEnvironment }); - const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); - const secretsSource = - secretsSourceRaw === 'auto' || secretsSourceRaw === 'env' || secretsSourceRaw === 'keychain' - ? secretsSourceRaw - : 'auto'; - if (secretsSourceRaw && secretsSource !== secretsSourceRaw) { - fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${secretsSourceRaw})`); - } - - const keychainService = String(values['keychain-service'] ?? '').trim() || 'happier/pipeline'; - const keychainAccount = String(values['keychain-account'] ?? '').trim() || undefined; - const { env: mergedEnv, usedKeychain } = loadSecrets({ - baseEnv: env, - secretsSource, - keychainService, - keychainAccount, - }); - if (sources.length > 0) { - console.log(`[pipeline] using env sources: ${sources.join(', ')}`); - console.log('[pipeline] warning: env-file mode is for fast local iteration; prefer Keychain bundle for long-term use.'); - } - if (usedKeychain) { - console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); - } - - console.log(`[pipeline] release: action=${action}`); - - const releaseChannel = action === 'release preview from dev' ? 'preview' : 'production'; - const releaseMessage = String(values['release-message'] ?? '').trim(); - - let sourceRef = 'dev'; - if (action === 'release dev to main') { - console.log('[pipeline] promote main from dev'); - runGithubPromoteBranch({ - repoRoot, - env: mergedEnv, - dryRun, - args: [ - '--source', - 'dev', - '--target', - 'main', - '--mode', - 'fast_forward', - '--allow-reset', - 'false', - '--confirm', - 'promote main from dev', - ...(dryRun ? ['--dry-run'] : []), - ], - }); - sourceRef = 'main'; - } else if (action === 'reset main from dev') { - console.log('[pipeline] reset main from dev'); - runGithubPromoteBranch({ - repoRoot, - env: mergedEnv, - dryRun, - args: [ - '--source', - 'dev', - '--target', - 'main', - '--mode', - 'reset', - '--allow-reset', - 'true', - '--confirm', - 'reset main from dev', - ...(dryRun ? ['--dry-run'] : []), - ], - }); - sourceRef = 'main'; - } - - const npmTargetsExplicit = parseCsvList(String(values['npm-targets'] ?? '')); - const npmTargetsDerived = [ - ...(deployTargets.includes('cli') ? ['cli'] : []), - ...(deployTargets.includes('stack') ? ['stack'] : []), - ...(deployTargets.includes('server_runner') ? ['server'] : []), - ]; - const npmTargets = npmTargetsExplicit.length > 0 ? npmTargetsExplicit : npmTargetsDerived; - const npmMode = String(values['npm-mode'] ?? '').trim() || 'pack+publish'; - const npmRunTests = String(values['npm-run-tests'] ?? '').trim() || 'true'; - const npmServerRunnerDir = String(values['npm-server-runner-dir'] ?? '').trim() || 'packages/relay-server'; - if (npmMode !== 'pack' && npmMode !== 'pack+publish') { - fail(`--npm-mode must be 'pack' or 'pack+publish' (got: ${npmMode})`); - } - for (const t of npmTargets) { - if (t !== 'cli' && t !== 'stack' && t !== 'server') { - fail(`--npm-targets contains unsupported target '${t}' (supported: cli, stack, server)`); - } - } - - if (npmTargets.length > 0) { - const publishCli = npmTargets.includes('cli') ? 'true' : 'false'; - const publishStack = npmTargets.includes('stack') ? 'true' : 'false'; - const publishServer = npmTargets.includes('server') ? 'true' : 'false'; - console.log(`[pipeline] release: npm channel=${releaseChannel} targets=${npmTargets.join(',')}`); - - runNpmReleasePackages({ - repoRoot, - env: mergedEnv, - dryRun, - args: [ - '--channel', - releaseChannel, - '--publish-cli', - publishCli, - '--publish-stack', - publishStack, - '--publish-server', - publishServer, - '--server-runner-dir', - npmServerRunnerDir, - '--run-tests', - npmRunTests, - '--mode', - npmMode, - ...(dryRun ? ['--dry-run'] : []), - ], - }); - } - - const publishDocker = resolveAutoBool(values['publish-docker'], '--publish-docker', releaseChannel === 'preview'); - const publishUiWeb = resolveAutoBool( - values['publish-ui-web'], - '--publish-ui-web', - releaseChannel === 'preview' && deployTargets.includes('ui'), - ); - const publishServerRuntime = resolveAutoBool( - values['publish-server-runtime'], - '--publish-server-runtime', - releaseChannel === 'preview' && deployTargets.includes('server_runner'), - ); - - if (releaseChannel === 'preview' && publishUiWeb) { - console.log('[pipeline] release: publish ui-web rolling release (preview)'); - runPublishUiWeb({ - repoRoot, - env: mergedEnv, - dryRun, - args: [ - '--channel', - 'preview', - '--allow-stable', - 'false', - '--release-message', - releaseMessage, - '--run-contracts', - 'true', - '--check-installers', - 'true', - ...(dryRun ? ['--dry-run'] : []), - ], - }); - } - - if (releaseChannel === 'preview' && publishServerRuntime) { - console.log('[pipeline] release: publish server-runtime rolling release (preview)'); - runPublishServerRuntime({ - repoRoot, - env: mergedEnv, - dryRun, - args: [ - '--channel', - 'preview', - '--allow-stable', - 'false', - '--release-message', - releaseMessage, - '--run-contracts', - 'true', - '--check-installers', - 'true', - ...(dryRun ? ['--dry-run'] : []), - ], - }); - } - - if (releaseChannel === 'preview' && publishDocker) { - console.log('[pipeline] release: publish docker images (preview)'); - console.log('[pipeline] docker publish: channel=preview'); - runDockerPublishImages({ - repoRoot, - env: mergedEnv, - dryRun, - args: [ - '--channel', - 'preview', - '--push-latest', - 'true', - '--build-relay', - 'true', - '--build-devcontainer', - 'true', - ...(dryRun ? ['--dry-run'] : []), - ], - }); - } - - const deployComponents = deployTargets.filter((t) => isDeployComponent(t)); - for (const component of deployComponents) { - const refName = `deploy/${deployEnvironment}/${component}`; - console.log(`[pipeline] promote deploy branch: ${refName} <= ${sourceRef}`); - - runGithubPromoteDeployBranch({ - repoRoot, - env: mergedEnv, - dryRun, - args: [ - '--deploy-environment', - deployEnvironment, - '--component', - component, - '--source-ref', - sourceRef, - ...(dryRun ? ['--dry-run'] : []), - ], - }); - - console.log(`[pipeline] trigger deploy webhooks: ${component}`); - runDeployWebhooks({ - repoRoot, - env: mergedEnv, - dryRun, - args: [ - '--environment', - deployEnvironment, - '--component', - component, - '--repository', - repository, - '--ref-name', - refName, - ...(dryRun ? ['--sha', '0123456789abcdef0123456789abcdef01234567'] : []), - ...(dryRun ? ['--dry-run'] : []), - ], - }); - } - - return; - } + if (subcommand === 'release') { + const { values } = parseArgs({ + args: rest, + options: { + confirm: { type: 'string' }, + repository: { type: 'string' }, + 'deploy-environment': { type: 'string', default: 'preview' }, + 'deploy-targets': { type: 'string', default: 'ui,server,website,docs' }, + 'force-deploy': { type: 'string', default: 'false' }, + bump: { type: 'string', default: 'none' }, + 'bump-app-override': { type: 'string', default: 'preset' }, + 'bump-cli-override': { type: 'string', default: 'preset' }, + 'bump-stack-override': { type: 'string', default: 'preset' }, + 'ui-expo-action': { type: 'string', default: 'none' }, + 'ui-expo-builder': { type: 'string', default: 'eas_cloud' }, + 'ui-expo-profile': { type: 'string', default: 'auto' }, + 'ui-expo-platform': { type: 'string', default: 'all' }, + 'desktop-mode': { type: 'string', default: 'none' }, + 'release-message': { type: 'string', default: '' }, + 'npm-mode': { type: 'string', default: 'pack+publish' }, + 'npm-run-tests': { type: 'string', default: 'auto' }, + 'npm-server-runner-dir': { type: 'string', default: 'packages/relay-server' }, + 'sync-dev-from-main': { type: 'string', default: 'true' }, + 'allow-dirty': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + 'secrets-source': { type: 'string', default: 'auto' }, + 'keychain-service': { type: 'string', default: 'happier/pipeline' }, + 'keychain-account': { type: 'string', default: '' }, + }, + allowPositionals: false, + }); + + const action = String(values.confirm ?? '').trim(); + if (!action) fail('--confirm is required (e.g. "release preview from dev")'); + if (action !== 'release preview from dev' && action !== 'release dev to main' && action !== 'reset main from dev') { + fail(`Unsupported --confirm action: ${action}`); + } + + const repository = String(values.repository ?? '').trim(); + if (!repository) fail('--repository is required (e.g. happier-dev/happier)'); + + const deployEnvironment = String(values['deploy-environment'] ?? '').trim(); + if (!isDeployEnvironment(deployEnvironment)) { + fail(`--deploy-environment must be 'production' or 'preview' (got: ${deployEnvironment || '<empty>'})`); + } + if (deployEnvironment === 'preview' && action !== 'release preview from dev') { + fail('Confirmation mismatch for preview releases. Expected: "release preview from dev"'); + } + if (deployEnvironment === 'production' && action === 'release preview from dev') { + fail('Confirmation mismatch for production releases. Expected: "release dev to main" or "reset main from dev"'); + } + + const deployTargets = parseCsvList(String(values['deploy-targets'] ?? '')); + if (deployTargets.length === 0) { + fail('--deploy-targets must not be empty'); + } + for (const t of deployTargets) { + if (!isReleaseTarget(t)) { + fail( + `--deploy-targets contains unsupported target '${t}' (supported: ui,server,website,docs,cli,stack,server_runner)`, + ); + } + } + + const dryRun = values['dry-run'] === true; + const allowDirty = parseBoolString(values['allow-dirty'], '--allow-dirty'); + if (!dryRun) assertCleanWorktree({ cwd: repoRoot, allowDirty }); + assertNoStagedChanges({ cwd: repoRoot, allowDirty, dryRun }); + + const forceDeploy = parseBoolString(values['force-deploy'], '--force-deploy'); + const bumpPreset = String(values.bump ?? '').trim() || 'none'; + const bumpAppOverride = String(values['bump-app-override'] ?? '').trim() || 'preset'; + const bumpCliOverride = String(values['bump-cli-override'] ?? '').trim() || 'preset'; + const bumpStackOverride = String(values['bump-stack-override'] ?? '').trim() || 'preset'; + + const uiExpoAction = String(values['ui-expo-action'] ?? '').trim() || 'none'; + const uiExpoBuilder = String(values['ui-expo-builder'] ?? '').trim() || 'eas_cloud'; + const uiExpoProfileRaw = String(values['ui-expo-profile'] ?? '').trim() || 'auto'; + const uiExpoPlatform = String(values['ui-expo-platform'] ?? '').trim() || 'all'; + const desktopMode = String(values['desktop-mode'] ?? '').trim() || 'none'; + const syncDevFromMain = parseBoolString(values['sync-dev-from-main'], '--sync-dev-from-main'); + + for (const [name, v] of [ + ['--bump', bumpPreset], + ['--bump-app-override', bumpAppOverride], + ['--bump-cli-override', bumpCliOverride], + ['--bump-stack-override', bumpStackOverride], + ]) { + if (!['none', 'patch', 'minor', 'major', 'preset'].includes(v)) { + fail(`${name} must be one of: none, patch, minor, major${name === '--bump' ? '' : ', preset'} (got: ${v})`); + } + } + if (!['none', 'ota', 'native', 'native_submit'].includes(uiExpoAction)) { + fail(`--ui-expo-action must be one of: none, ota, native, native_submit (got: ${uiExpoAction})`); + } + if (!['eas_cloud', 'eas_local'].includes(uiExpoBuilder)) { + fail(`--ui-expo-builder must be one of: eas_cloud, eas_local (got: ${uiExpoBuilder})`); + } + if (!['auto', 'preview', 'preview-apk', 'production', 'production-apk'].includes(uiExpoProfileRaw)) { + fail(`--ui-expo-profile must be one of: auto, preview, preview-apk, production, production-apk (got: ${uiExpoProfileRaw})`); + } + if (!['ios', 'android', 'all'].includes(uiExpoPlatform)) { + fail(`--ui-expo-platform must be one of: ios, android, all (got: ${uiExpoPlatform})`); + } + if (!['none', 'build_only', 'build_and_publish'].includes(desktopMode)) { + fail(`--desktop-mode must be one of: none, build_only, build_and_publish (got: ${desktopMode})`); + } + + const npmMode = String(values['npm-mode'] ?? '').trim() || 'pack+publish'; + const npmRunTests = String(values['npm-run-tests'] ?? '').trim() || 'auto'; + const npmServerRunnerDir = String(values['npm-server-runner-dir'] ?? '').trim() || 'packages/relay-server'; + if (npmMode !== 'pack' && npmMode !== 'pack+publish') { + fail(`--npm-mode must be 'pack' or 'pack+publish' (got: ${npmMode})`); + } + + const { env, sources } = loadPipelineEnv({ repoRoot, deployEnvironment }); + const secretsSourceRaw = String(values['secrets-source'] ?? '').trim(); + const secretsSource = + secretsSourceRaw === 'auto' || secretsSourceRaw === 'env' || secretsSourceRaw === 'keychain' + ? secretsSourceRaw + : 'auto'; + if (secretsSourceRaw && secretsSource !== secretsSourceRaw) { + fail(`--secrets-source must be 'auto', 'env', or 'keychain' (got: ${secretsSourceRaw})`); + } + + const keychainService = String(values['keychain-service'] ?? '').trim() || 'happier/pipeline'; + const keychainAccount = String(values['keychain-account'] ?? '').trim() || undefined; + const { env: mergedEnv, usedKeychain } = loadSecrets({ + baseEnv: env, + secretsSource, + keychainService, + keychainAccount, + }); + if (sources.length > 0) { + console.log(`[pipeline] using env sources: ${sources.join(', ')}`); + console.log('[pipeline] warning: env-file mode is for fast local iteration; prefer Keychain bundle for long-term use.'); + } + if (usedKeychain) { + console.log(`[pipeline] loaded secrets from Keychain service '${keychainService}'`); + } + + /** @type {Record<string, string>} */ + const releaseEnv = { + ...mergedEnv, + GH_REPO: mergedEnv.GH_REPO ?? repository, + GITHUB_REPOSITORY: mergedEnv.GITHUB_REPOSITORY ?? repository, + }; + + const releaseMessage = String(values['release-message'] ?? '').trim(); + console.log(`[pipeline] release: environment=${deployEnvironment} confirm=${action}`); + + // Ensure all preview release steps compute the same preview.<run>.<attempt> suffix when running locally. + if (deployEnvironment === 'preview' && !String(releaseEnv.GITHUB_RUN_NUMBER ?? '').trim()) { + const runNumber = String(Math.floor(Date.now() / 1000)); + releaseEnv.GITHUB_RUN_NUMBER = runNumber; + if (!String(releaseEnv.GITHUB_RUN_ATTEMPT ?? '').trim()) { + releaseEnv.GITHUB_RUN_ATTEMPT = '1'; + } + console.log( + `[pipeline] preview version suffix: preview.${releaseEnv.GITHUB_RUN_NUMBER}.${releaseEnv.GITHUB_RUN_ATTEMPT}`, + ); + } + + // Plan: compute changed components (main..dev) and resolve bump/publish plan. + console.log('[pipeline] release: fetching origin main/dev for plan'); + const fetchTagsArg = dryRun ? '--no-tags' : '--tags'; + execFileSync('git', ['fetch', 'origin', 'main', 'dev', '--prune', fetchTagsArg], { + cwd: repoRoot, + env: process.env, + stdio: 'inherit', + timeout: 120_000, + }); + + const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: repoRoot, + env: process.env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }).trim(); + if (currentBranch !== 'dev') { + fail(`Local release expects to run from branch 'dev' (current: ${currentBranch}).`); + } + + const devSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + env: process.env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }).trim(); + const mainSha = execFileSync('git', ['rev-parse', 'origin/main'], { + cwd: repoRoot, + env: process.env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }).trim(); + + const changedRaw = runJsonScript({ + repoRoot, + env: { ...process.env }, + scriptRel: 'scripts/pipeline/release/compute-changed-components.mjs', + args: ['--base', mainSha, '--head', devSha], + }); + + const changed = { + changed_ui: String(changedRaw?.changed_ui ?? '').trim() === 'true', + changed_cli: String(changedRaw?.changed_cli ?? '').trim() === 'true', + changed_server: String(changedRaw?.changed_server ?? '').trim() === 'true', + changed_website: String(changedRaw?.changed_website ?? '').trim() === 'true', + changed_docs: String(changedRaw?.changed_docs ?? '').trim() === 'true', + changed_shared: String(changedRaw?.changed_shared ?? '').trim() === 'true', + changed_stack: String(changedRaw?.changed_stack ?? '').trim() === 'true', + }; + + const bumpPlanRaw = runJsonScript({ + repoRoot, + env: { ...process.env }, + scriptRel: 'scripts/pipeline/release/resolve-bump-plan.mjs', + args: [ + '--environment', + deployEnvironment, + '--bump-preset', + bumpPreset, + '--bump-app-override', + bumpAppOverride, + '--bump-cli-override', + bumpCliOverride, + '--bump-stack-override', + bumpStackOverride, + '--deploy-targets', + deployTargets.join(','), + '--changed-ui', + changed.changed_ui ? 'true' : 'false', + '--changed-cli', + changed.changed_cli ? 'true' : 'false', + '--changed-stack', + changed.changed_stack ? 'true' : 'false', + '--changed-server', + changed.changed_server ? 'true' : 'false', + '--changed-website', + changed.changed_website ? 'true' : 'false', + '--changed-shared', + changed.changed_shared ? 'true' : 'false', + ], + }); + + const bumpPlan = { + bump_app: String(bumpPlanRaw?.bump_app ?? 'none'), + bump_cli: String(bumpPlanRaw?.bump_cli ?? 'none'), + bump_stack: String(bumpPlanRaw?.bump_stack ?? 'none'), + bump_server: String(bumpPlanRaw?.bump_server ?? 'none'), + bump_website: String(bumpPlanRaw?.bump_website ?? 'none'), + should_bump: String(bumpPlanRaw?.should_bump ?? '').trim() === 'true', + publish_cli: String(bumpPlanRaw?.publish_cli ?? '').trim() === 'true', + publish_stack: String(bumpPlanRaw?.publish_stack ?? '').trim() === 'true', + publish_server: String(bumpPlanRaw?.publish_server ?? '').trim() === 'true', + }; + + console.log('[pipeline] release plan: changed components (main..dev)'); + for (const [k, v] of Object.entries(changed)) { + console.log(`- ${k.replace(/^changed_/, '')}: ${v}`); + } + console.log('[pipeline] release plan: bump/publish'); + console.log( + `- bump_app=${bumpPlan.bump_app} bump_server=${bumpPlan.bump_server} bump_website=${bumpPlan.bump_website} bump_cli=${bumpPlan.bump_cli} bump_stack=${bumpPlan.bump_stack}`, + ); + console.log( + `- publish_cli=${bumpPlan.publish_cli} publish_stack=${bumpPlan.publish_stack} publish_server=${bumpPlan.publish_server}`, + ); + + /** + * @param {string} sourceRef + */ + const computeDeployPlan = (sourceRef) => + runJsonScript({ + repoRoot, + env: { ...process.env }, + scriptRel: 'scripts/pipeline/release/compute-deploy-plan.mjs', + args: [ + '--deploy-environment', + deployEnvironment, + '--source-ref', + sourceRef, + '--force-deploy', + forceDeploy ? 'true' : 'false', + '--deploy-ui', + deployEnvironment === 'production' && deployTargets.includes('ui') ? 'true' : 'false', + '--deploy-server', + deployTargets.includes('server') ? 'true' : 'false', + '--deploy-website', + deployTargets.includes('website') ? 'true' : 'false', + '--deploy-docs', + deployTargets.includes('docs') ? 'true' : 'false', + ], + }); + + if (dryRun) { + const sourceRef = deployEnvironment === 'production' ? 'main' : 'dev'; + const deployPlan = computeDeployPlan(sourceRef); + const uiExpoProfile = uiExpoProfileRaw === 'auto' ? deployEnvironment : uiExpoProfileRaw; + const predicted = computeReleaseExecutionPlan({ + environment: deployEnvironment, + dryRun: false, + forceDeploy, + deployTargets, + uiExpoAction, + desktopMode, + changed, + bumpPlan, + deployPlan, + }); + + console.log('[pipeline] dry-run: would run'); + for (const [k, v] of Object.entries(predicted)) { + console.log(`- ${k}: ${v}`); + } + if (uiExpoAction !== 'none') { + console.log( + `[pipeline] dry-run: ui expo action configured (action=${uiExpoAction} builder=${uiExpoBuilder} platform=${uiExpoPlatform} profile=${uiExpoProfile})`, + ); + } + if (desktopMode !== 'none') { + console.log(`[pipeline] dry-run: desktop mode configured (${desktopMode}); use GitHub Actions for full matrix builds.`); + } + return; + } + + // Apply bumps (dev commit) if requested. + if (bumpPlan.should_bump) { + console.log('[pipeline] release: apply version bumps (dev)'); + runReleaseBumpVersionsDev({ + repoRoot, + env: { ...process.env }, + dryRun: false, + args: [ + '--bump-app', + bumpPlan.bump_app, + '--bump-server', + bumpPlan.bump_server, + '--bump-website', + bumpPlan.bump_website, + '--bump-cli', + bumpPlan.bump_cli, + '--bump-stack', + bumpPlan.bump_stack, + ], + }); + } + + // Promote main from dev for production. + if (deployEnvironment === 'production') { + const promoteMode = action === 'reset main from dev' ? 'reset' : 'fast_forward'; + const allowReset = action === 'reset main from dev' ? 'true' : 'false'; + const confirmPhrase = action === 'reset main from dev' ? 'reset main from dev' : 'promote main from dev'; + console.log(`[pipeline] release: promote main from dev (mode=${promoteMode})`); + runGithubPromoteBranch({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--source', + 'dev', + '--target', + 'main', + '--mode', + promoteMode, + '--allow-reset', + allowReset, + '--confirm', + confirmPhrase, + ], + }); + } + + const releaseSourceRef = deployEnvironment === 'production' ? 'main' : 'dev'; + const deployPlan = computeDeployPlan(releaseSourceRef); + + const execution = computeReleaseExecutionPlan({ + environment: deployEnvironment, + dryRun: false, + forceDeploy, + deployTargets, + uiExpoAction, + desktopMode, + changed, + bumpPlan, + deployPlan, + }); + + console.log('[pipeline] release: execution plan'); + for (const [k, v] of Object.entries(execution)) { + console.log(`- ${k}: ${v}`); + } + + // Expo actions (handled via promote-ui in GitHub; run directly here). + const uiExpoProfile = uiExpoProfileRaw === 'auto' ? deployEnvironment : uiExpoProfileRaw; + if (uiExpoAction === 'ota') { + console.log(`[pipeline] release: expo ota (${deployEnvironment})`); + runExpoOtaUpdate({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--environment', + deployEnvironment, + ...(releaseMessage ? ['--message', releaseMessage] : []), + ], + }); + } else if (uiExpoAction === 'native' || uiExpoAction === 'native_submit') { + const buildMode = uiExpoBuilder === 'eas_cloud' ? 'cloud' : 'local'; + const actionName = uiExpoAction; + const platforms = uiExpoPlatform === 'all' ? ['android', 'ios'] : [uiExpoPlatform]; + for (const p of platforms) { + const localRuntime = buildMode === 'local' ? (p === 'android' ? 'dagger' : 'host') : ''; + console.log(`[pipeline] release: expo ${actionName} (${p}) mode=${buildMode}${localRuntime ? ` runtime=${localRuntime}` : ''}`); + runUiMobileRelease({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--environment', + deployEnvironment, + '--action', + actionName, + '--platform', + p, + '--profile', + uiExpoProfile, + ...(buildMode === 'cloud' ? ['--native-build-mode', 'cloud'] : ['--native-build-mode', 'local']), + ...(buildMode === 'local' ? ['--native-local-runtime', localRuntime] : []), + ...(releaseMessage ? ['--release-message', releaseMessage] : []), + ], + }); + } + } + + if (desktopMode !== 'none') { + console.warn('[pipeline] desktop builds are currently recommended via GitHub Actions (build-tauri.yml) for full platform coverage.'); + } + + // Preview-only publishing surfaces. + if (execution.runPublishUiWeb) { + console.log('[pipeline] release: publish ui-web (preview rolling)'); + runPublishUiWeb({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--channel', + 'preview', + '--allow-stable', + 'false', + '--release-message', + releaseMessage, + '--run-contracts', + 'auto', + '--check-installers', + 'true', + ], + }); + } + if (execution.runPublishServerRuntime) { + console.log('[pipeline] release: publish server-runtime (preview rolling)'); + runPublishServerRuntime({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--channel', + 'preview', + '--allow-stable', + 'false', + '--release-message', + releaseMessage, + '--run-contracts', + 'auto', + '--check-installers', + 'true', + ], + }); + } + if (execution.runPublishDocker) { + console.log('[pipeline] release: publish docker images (preview)'); + runDockerPublishImages({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--channel', + 'preview', + '--push-latest', + 'true', + '--build-relay', + execution.dockerBuildRelay ? 'true' : 'false', + '--build-dev-box', + execution.dockerBuildDevBox ? 'true' : 'false', + ], + }); + } + + // CLI/stack rolling binaries (preview/stable based on environment). + const rollingChannel = deployEnvironment === 'production' ? 'stable' : 'preview'; + const allowStable = deployEnvironment === 'production' ? 'true' : 'false'; + if (execution.runPublishCliBinaries) { + console.log(`[pipeline] release: publish cli binaries (${rollingChannel})`); + runPublishCliBinaries({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--channel', + rollingChannel, + '--allow-stable', + allowStable, + '--release-message', + releaseMessage, + '--run-contracts', + 'auto', + '--check-installers', + 'true', + ], + }); + } + if (execution.runPublishHstackBinaries) { + console.log(`[pipeline] release: publish hstack binaries (${rollingChannel})`); + runPublishHstackBinaries({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--channel', + rollingChannel, + '--allow-stable', + allowStable, + '--release-message', + releaseMessage, + '--run-contracts', + 'auto', + '--check-installers', + 'true', + ], + }); + } + + // npm packages (preview=next, production=latest) + if (execution.runPublishNpm) { + console.log(`[pipeline] release: npm channel=${deployEnvironment}`); + runNpmReleasePackages({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--channel', + deployEnvironment, + '--publish-cli', + bumpPlan.publish_cli ? 'true' : 'false', + '--publish-stack', + bumpPlan.publish_stack ? 'true' : 'false', + '--publish-server', + bumpPlan.publish_server ? 'true' : 'false', + '--server-runner-dir', + npmServerRunnerDir, + '--run-tests', + npmRunTests, + '--mode', + npmMode, + ], + }); + } + + /** + * @param {'ui'|'server'|'website'|'docs'} component + */ + const deployOne = (component) => { + const refName = `deploy/${deployEnvironment}/${component}`; + console.log(`[pipeline] promote deploy branch: ${refName} <= ${releaseSourceRef}`); + runGithubPromoteDeployBranch({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--deploy-environment', + deployEnvironment, + '--component', + component, + '--source-ref', + releaseSourceRef, + ], + }); + + console.log(`[pipeline] trigger deploy webhooks: ${component}`); + runDeployWebhooks({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--environment', + deployEnvironment, + '--component', + component, + '--repository', + repository, + '--ref-name', + refName, + ], + }); + }; + + // UI web deploy is production-only under current policy. + if (execution.runDeployUi && deployEnvironment === 'production' && deployTargets.includes('ui')) { + deployOne('ui'); + } + if (execution.runDeployServer && deployTargets.includes('server')) { + deployOne('server'); + } + if (execution.runDeployWebsite && deployTargets.includes('website')) { + deployOne('website'); + } + if (execution.runDeployDocs && deployTargets.includes('docs')) { + deployOne('docs'); + } + + if (deployEnvironment === 'production' && syncDevFromMain) { + console.log('[pipeline] release: sync dev from main'); + runGithubPromoteBranch({ + repoRoot, + env: releaseEnv, + dryRun: false, + args: [ + '--source', + 'main', + '--target', + 'dev', + '--mode', + 'fast_forward', + '--allow-reset', + 'false', + '--confirm', + 'promote dev from main', + ], + }); + } + + return; + } } main(); diff --git a/scripts/pipeline/smoke/cli-smoke.mjs b/scripts/pipeline/smoke/cli-smoke.mjs new file mode 100644 index 000000000..54d2d45f1 --- /dev/null +++ b/scripts/pipeline/smoke/cli-smoke.mjs @@ -0,0 +1,198 @@ +// @ts-check + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {unknown} value + * @param {string} name + */ +function parseBool(value, name) { + const raw = String(value ?? '').trim().toLowerCase(); + if (raw === 'true') return true; + if (raw === 'false') return false; + fail(`${name} must be 'true' or 'false' (got: ${value})`); +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cmd + * @param {string[]} args + * @param {{ cwd?: string; env?: Record<string, string>; stdio?: import('node:child_process').StdioOptions; timeoutMs?: number; }} [extra] + * @returns {string} + */ +function run(opts, cmd, args, extra) { + const printable = `${cmd} ${args.map((a) => (a.includes(' ') ? JSON.stringify(a) : a)).join(' ')}`; + const cwd = extra?.cwd ? path.resolve(extra.cwd) : process.cwd(); + const timeout = extra?.timeoutMs ?? 10 * 60_000; + if (opts.dryRun) { + console.log(`[dry-run] (cwd: ${cwd}) ${printable}`); + return ''; + } + + const stdio = extra?.stdio ?? 'inherit'; + const needsShell = + process.platform === 'win32' && (String(cmd).toLowerCase().endsWith('.cmd') || String(cmd).toLowerCase().endsWith('.bat')); + return execFileSync(cmd, args, { + cwd, + env: { ...process.env, ...(extra?.env ?? {}) }, + encoding: stdio === 'inherit' ? 'utf8' : 'utf8', + stdio, + shell: needsShell, + timeout, + }); +} + +/** + * @param {string} repoRoot + * @param {string} rel + */ +function withinRepo(repoRoot, rel) { + return path.resolve(repoRoot, rel); +} + +/** + * @param {string} prefix + * @returns {string} + */ +function mkTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +/** + * @param {string} pkgDir + * @param {string} destDir + * @param {{ dryRun: boolean }} opts + * @returns {string} absolute tgz path + */ +function npmPack(pkgDir, destDir, opts) { + if (opts.dryRun) { + const printable = `npm pack --silent --pack-destination ${destDir}`; + console.log(`[dry-run] (cwd: ${pkgDir}) ${printable}`); + return path.join(destDir, 'DRY_RUN.tgz'); + } + + fs.mkdirSync(destDir, { recursive: true }); + const raw = execFileSync('npm', ['pack', '--silent', '--pack-destination', destDir], { + cwd: pkgDir, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + timeout: 10 * 60_000, + }).trim(); + + const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const filename = lines.length > 0 ? lines[lines.length - 1] : ''; + if (!filename) { + throw new Error(`npm pack did not return a tarball filename (cwd: ${pkgDir})`); + } + const tgzPath = path.resolve(destDir, filename); + if (!tgzPath.endsWith('.tgz') || !fs.existsSync(tgzPath) || !fs.statSync(tgzPath).isFile()) { + throw new Error(`npm pack did not produce an expected .tgz file (cwd: ${pkgDir}): ${tgzPath}`); + } + return tgzPath; +} + +/** + * @param {string} prefixDir + * @returns {string} + */ +function resolveInstalledBin(prefixDir) { + const exe = process.platform === 'win32' ? 'happier.cmd' : 'happier'; + + const env = { ...process.env, npm_config_prefix: prefixDir }; + let binDir = ''; + try { + binDir = execFileSync('npm', ['bin', '-g'], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + .trim() + .split(/\r?\n/)[0] + .trim(); + } catch { + binDir = ''; + } + + const candidates = [ + ...(binDir ? [path.join(binDir, exe)] : []), + path.join(prefixDir, 'bin', exe), + path.join(prefixDir, exe), + path.join(prefixDir, 'node_modules', '.bin', exe), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } + + fail(`Unable to locate installed CLI binary under prefix ${prefixDir} (looked for: ${exe})`); +} + +function main() { + const repoRoot = path.resolve(process.cwd()); + const { values } = parseArgs({ + options: { + 'package-dir': { type: 'string', default: 'apps/cli' }, + 'workspace-name': { type: 'string', default: '@happier-dev/cli' }, + 'skip-build': { type: 'string', default: 'false' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const pkgDir = String(values['package-dir'] ?? '').trim() || 'apps/cli'; + const workspaceName = String(values['workspace-name'] ?? '').trim() || '@happier-dev/cli'; + const skipBuild = parseBool(values['skip-build'], '--skip-build'); + const dryRun = values['dry-run'] === true; + const opts = { dryRun }; + + const absPkgDir = withinRepo(repoRoot, pkgDir); + if (!fs.existsSync(absPkgDir)) { + fail(`package dir not found: ${pkgDir}`); + } + + const prefixDir = dryRun ? withinRepo(repoRoot, 'dist/smoke/DRY_RUN_PREFIX') : mkTmpDir('happier-cli-smoke-prefix-'); + const homeDir = dryRun ? withinRepo(repoRoot, 'dist/smoke/DRY_RUN_HOME') : mkTmpDir('happier-cli-smoke-home-'); + const packDir = dryRun ? withinRepo(repoRoot, 'dist/smoke/DRY_RUN_PACK') : mkTmpDir('happier-cli-smoke-pack-'); + + if (!skipBuild) { + run(opts, 'yarn', ['workspace', workspaceName, 'build'], { cwd: repoRoot }); + } + + const tgzPath = npmPack(absPkgDir, packDir, opts); + + run(opts, 'npm', ['install', '-g', '--prefix', prefixDir, tgzPath], { cwd: repoRoot }); + + const binPath = opts.dryRun ? path.join(prefixDir, process.platform === 'win32' ? 'happier.cmd' : 'bin/happier') : resolveInstalledBin(prefixDir); + + const baseEnv = { ...process.env, HAPPIER_HOME_DIR: homeDir }; + + run(opts, binPath, ['--help'], { cwd: repoRoot, env: baseEnv, stdio: opts.dryRun ? 'inherit' : ['ignore', 'inherit', 'inherit'], timeoutMs: 30_000 }); + run(opts, binPath, ['--version'], { cwd: repoRoot, env: baseEnv, stdio: opts.dryRun ? 'inherit' : ['ignore', 'inherit', 'inherit'], timeoutMs: 10_000 }); + + const doctor = run(opts, binPath, ['doctor', '--help'], { cwd: repoRoot, env: baseEnv, stdio: ['ignore', 'pipe', 'inherit'], timeoutMs: 10_000 }); + if (!opts.dryRun && doctor) { + process.stdout.write(doctor); + if (!doctor.endsWith('\n')) process.stdout.write('\n'); + } + + const daemonHelp = run(opts, binPath, ['daemon', '--help'], { cwd: repoRoot, env: baseEnv, stdio: ['ignore', 'pipe', 'inherit'], timeoutMs: 10_000 }); + if (!opts.dryRun) { + process.stdout.write(daemonHelp); + if (!daemonHelp.endsWith('\n')) process.stdout.write('\n'); + if (!daemonHelp.includes('Daemon management')) { + fail('Expected `happier daemon --help` to include "Daemon management"'); + } + } + + console.log('[smoke] CLI smoke test passed.'); +} + +main(); diff --git a/scripts/pipeline/tauri/build-updater-artifacts.mjs b/scripts/pipeline/tauri/build-updater-artifacts.mjs new file mode 100644 index 000000000..f9c5b2c77 --- /dev/null +++ b/scripts/pipeline/tauri/build-updater-artifacts.mjs @@ -0,0 +1,161 @@ +// @ts-check + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; + +import { ensureTauriSigningKeyFile } from './ensure-signing-key-file.mjs'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {unknown} value + * @param {string} name + */ +function parseBool(value, name) { + const raw = String(value ?? '').trim().toLowerCase(); + if (raw === 'true') return true; + if (raw === 'false') return false; + fail(`${name} must be 'true' or 'false' (got: ${value})`); +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cmd + * @param {string[]} args + * @param {{ cwd: string; env?: Record<string, string>; timeoutMs?: number; stdio?: import('node:child_process').StdioOptions }} extra + */ +function run(opts, cmd, args, extra) { + const printable = `${cmd} ${args.map((a) => (a.includes(' ') ? JSON.stringify(a) : a)).join(' ')}`; + if (opts.dryRun) { + console.log(`[dry-run] (cwd: ${extra.cwd}) ${printable}`); + return; + } + + execFileSync(cmd, args, { + cwd: extra.cwd, + env: { ...process.env, ...(extra.env ?? {}) }, + stdio: extra.stdio ?? 'inherit', + timeout: extra.timeoutMs ?? 30 * 60_000, + }); +} + +/** + * @param {string} dir + * @param {string} filename + */ +function tempFile(dir, filename) { + fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, filename); +} + +function main() { + const repoRoot = path.resolve(process.cwd()); + const { values } = parseArgs({ + options: { + environment: { type: 'string' }, + 'build-version': { type: 'string', default: '' }, + 'tauri-target': { type: 'string', default: '' }, + 'ui-dir': { type: 'string', default: 'apps/ui' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const environment = String(values.environment ?? '').trim(); + if (environment !== 'preview' && environment !== 'production') { + fail(`--environment must be 'preview' or 'production' (got: ${environment || '<empty>'})`); + } + + const buildVersion = String(values['build-version'] ?? '').trim(); + if (environment === 'preview' && !buildVersion) { + fail('--build-version is required when --environment preview'); + } + + const tauriTarget = String(values['tauri-target'] ?? '').trim(); + const uiDir = String(values['ui-dir'] ?? '').trim() || 'apps/ui'; + const dryRun = values['dry-run'] === true; + const opts = { dryRun }; + + const absUiDir = path.resolve(repoRoot, uiDir); + if (!fs.existsSync(absUiDir)) { + fail(`ui dir not found: ${uiDir}`); + } + + const tmpRoot = String(process.env.RUNNER_TEMP ?? '').trim() || os.tmpdir(); + + if (tauriTarget) { + run(opts, 'rustup', ['target', 'add', tauriTarget], { cwd: absUiDir, timeoutMs: 10 * 60_000 }); + } + + const targetArgs = tauriTarget ? ['--target', tauriTarget] : []; + /** @type {string[]} */ + const configs = []; + + const signingKeyValue = String(process.env.TAURI_SIGNING_PRIVATE_KEY ?? '').trim(); + const signingKeyPath = signingKeyValue + ? ensureTauriSigningKeyFile({ tmpRoot, keyValue: signingKeyValue, dryRun: opts.dryRun }) + : ''; + if (signingKeyPath) { + const updaterOverridePath = tempFile(tmpRoot, 'tauri.updater.override.json'); + if (opts.dryRun) { + console.log(`[dry-run] write ${updaterOverridePath} (enable updater artifacts)`); + } else { + const payload = { bundle: { createUpdaterArtifacts: true } }; + fs.writeFileSync(updaterOverridePath, `${JSON.stringify(payload)}\n`, 'utf8'); + } + configs.push('--config', updaterOverridePath); + } + + const appleSigningIdentity = String(process.env.APPLE_SIGNING_IDENTITY ?? '').trim(); + if (process.platform === 'darwin' && appleSigningIdentity) { + const codesignOverride = tempFile(tmpRoot, 'tauri.codesign.override.json'); + if (opts.dryRun) { + console.log(`[dry-run] write ${codesignOverride} (macOS signingIdentity=${appleSigningIdentity})`); + } else { + const payload = { bundle: { macOS: { signingIdentity: appleSigningIdentity, hardenedRuntime: true } } }; + fs.writeFileSync(codesignOverride, `${JSON.stringify(payload)}\n`, 'utf8'); + } + configs.push('--config', codesignOverride); + } + + if (environment === 'preview') { + const versionOverride = tempFile(tmpRoot, 'tauri.version.override.json'); + if (opts.dryRun) { + console.log(`[dry-run] write ${versionOverride} (version=${buildVersion})`); + } else { + fs.writeFileSync(versionOverride, `${JSON.stringify({ version: buildVersion })}\n`, 'utf8'); + } + + run( + opts, + 'yarn', + ['tauri', 'build', '--config', 'src-tauri/tauri.preview.conf.json', '--config', versionOverride, ...configs, ...targetArgs], + { + cwd: absUiDir, + env: { + CI: 'true', + APP_ENV: environment, + ...(signingKeyPath ? { TAURI_SIGNING_PRIVATE_KEY: signingKeyPath } : {}), + }, + }, + ); + return; + } + + run(opts, 'yarn', ['tauri', 'build', ...configs, ...targetArgs], { + cwd: absUiDir, + env: { + CI: 'true', + APP_ENV: environment, + ...(signingKeyPath ? { TAURI_SIGNING_PRIVATE_KEY: signingKeyPath } : {}), + }, + }); +} + +main(); diff --git a/scripts/pipeline/tauri/collect-updater-artifacts.mjs b/scripts/pipeline/tauri/collect-updater-artifacts.mjs new file mode 100644 index 000000000..55fd08c0d --- /dev/null +++ b/scripts/pipeline/tauri/collect-updater-artifacts.mjs @@ -0,0 +1,193 @@ +// @ts-check + +import fs from 'node:fs'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {string} dir + * @returns {string[]} + */ +function listFilesRecursive(dir) { + /** @type {string[]} */ + const out = []; + /** @type {string[]} */ + const stack = [dir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const abs = path.join(current, entry.name); + if (entry.isDirectory()) stack.push(abs); + else if (entry.isFile()) out.push(abs); + } + } + return out; +} + +/** + * @param {string} absPath + */ +function rel(absPath) { + return path.relative(process.cwd(), absPath) || absPath; +} + +/** + * @param {string[]} files + * @param {(p: string) => boolean} predicate + */ +function findMatching(files, predicate) { + return files.filter(predicate).sort((a, b) => a.localeCompare(b)); +} + +/** + * @param {string} platformKey + * @param {string[]} matches + * @returns {string} + */ +function pickSignature(platformKey, matches) { + if (platformKey.startsWith('windows-')) { + const preferred = matches.find((m) => m.endsWith('.nsis.zip.sig')) || matches.find((m) => m.endsWith('.exe.zip.sig')) || matches[0]; + if (!preferred) fail(`Expected at least one Windows updater signature; found 0`); + if (matches.length > 1) { + console.error(`Found multiple Windows updater signatures for ${platformKey}; using preferred artifact: ${rel(preferred)}`); + console.error('All matches:'); + for (const match of matches) console.error(` ${rel(match)}`); + } + return preferred; + } + + if (matches.length !== 1) { + fail(`Expected exactly one updater signature for ${platformKey}; found ${matches.length}`); + } + return matches[0]; +} + +/** + * @param {string} artifactFilename + */ +function resolveArtifactExt(artifactFilename) { + if (artifactFilename.endsWith('.msi.zip')) return '.msi.zip'; + if (artifactFilename.endsWith('.exe.zip')) return '.exe.zip'; + if (artifactFilename.endsWith('.nsis.zip')) return '.nsis.zip'; + if (artifactFilename.endsWith('.app.tar.gz')) return '.app.tar.gz'; + if (artifactFilename.endsWith('.AppImage.tar.gz')) return '.AppImage.tar.gz'; + if (artifactFilename.endsWith('.appimage.tar.gz')) return '.appimage.tar.gz'; + if (artifactFilename.includes('.')) return `.${artifactFilename.split('.').pop()}`; + return ''; +} + +function main() { + const repoRoot = path.resolve(process.cwd()); + const { values } = parseArgs({ + options: { + environment: { type: 'string' }, + 'platform-key': { type: 'string' }, + 'ui-version': { type: 'string' }, + 'tauri-target': { type: 'string', default: '' }, + 'ui-dir': { type: 'string', default: 'apps/ui' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const environment = String(values.environment ?? '').trim(); + if (environment !== 'preview' && environment !== 'production') { + fail(`--environment must be 'preview' or 'production' (got: ${environment || '<empty>'})`); + } + + const platformKey = String(values['platform-key'] ?? '').trim(); + if (!platformKey) fail('--platform-key is required'); + if (!platformKey.startsWith('windows-') && !platformKey.startsWith('darwin-') && !platformKey.startsWith('linux-')) { + fail(`Unknown platform key: ${platformKey}`); + } + + const uiVersion = String(values['ui-version'] ?? '').trim(); + if (!uiVersion) fail('--ui-version is required'); + + const tauriTarget = String(values['tauri-target'] ?? '').trim(); + const uiDir = String(values['ui-dir'] ?? '').trim() || 'apps/ui'; + const dryRun = values['dry-run'] === true; + + const absUiDir = path.resolve(repoRoot, uiDir); + const baseDir = path.join(absUiDir, 'src-tauri', 'target'); + const searchDir = tauriTarget ? path.join(baseDir, tauriTarget) : baseDir; + + const outDir = path.join(repoRoot, 'dist', 'tauri', 'updates', platformKey); + const outBase = + environment === 'preview' ? `happier-ui-desktop-preview-${platformKey}` : `happier-ui-desktop-${platformKey}-v${uiVersion}`; + + if (dryRun) { + console.log(`[dry-run] search: ${rel(searchDir)}`); + console.log(`[dry-run] output: ${rel(outDir)}`); + console.log(`[dry-run] out_base: ${outBase}`); + } + + const files = dryRun ? [] : listFilesRecursive(searchDir); + const signatureMatches = findMatching(files, (p) => { + const normalized = p.replaceAll(path.sep, '/'); + if (!normalized.includes('/release/bundle/')) return false; + const lower = p.toLowerCase(); + + if (platformKey.startsWith('windows-')) { + return lower.endsWith('.msi.zip.sig') || lower.endsWith('.exe.zip.sig') || lower.endsWith('.nsis.zip.sig'); + } + if (platformKey.startsWith('darwin-')) { + return lower.endsWith('.app.tar.gz.sig'); + } + return lower.endsWith('.appimage.sig') || lower.endsWith('.appimage.tar.gz.sig'); + }); + + const sigPath = dryRun ? path.join(searchDir, 'DRY_RUN.sig') : pickSignature(platformKey, signatureMatches); + const artifactPath = sigPath.endsWith('.sig') ? sigPath.slice(0, -'.sig'.length) : sigPath; + + if (!dryRun) { + if (signatureMatches.length < 1) { + fail(`Unable to find updater signature under: ${rel(searchDir)}/**/release/bundle`); + } + if (!fs.existsSync(artifactPath) || !fs.statSync(artifactPath).isFile()) { + fail(`Missing updater artifact for signature: ${rel(sigPath)}`); + } + } + + const ext = resolveArtifactExt(path.basename(artifactPath)); + const outArtifact = path.join(outDir, `${outBase}${ext}`); + const outSig = `${outArtifact}.sig`; + + if (dryRun) { + console.log(`[dry-run] cp ${rel(artifactPath)} -> ${rel(outArtifact)}`); + console.log(`[dry-run] cp ${rel(sigPath)} -> ${rel(outSig)}`); + } else { + fs.mkdirSync(outDir, { recursive: true }); + fs.copyFileSync(artifactPath, outArtifact); + fs.copyFileSync(sigPath, outSig); + } + + if (platformKey.startsWith('darwin-')) { + if (dryRun) { + console.log(`[dry-run] maybe copy *.dmg -> ${rel(path.join(outDir, `${outBase}.dmg`))}`); + return; + } + const dmgCandidates = findMatching(files, (p) => { + const normalized = p.replaceAll(path.sep, '/'); + return normalized.includes('/release/bundle/') && p.toLowerCase().endsWith('.dmg'); + }); + if (dmgCandidates.length > 0) { + fs.copyFileSync(dmgCandidates[0], path.join(outDir, `${outBase}.dmg`)); + } + } +} + +main(); + diff --git a/scripts/pipeline/tauri/ensure-signing-key-file.mjs b/scripts/pipeline/tauri/ensure-signing-key-file.mjs new file mode 100644 index 000000000..7b3ab0aa9 --- /dev/null +++ b/scripts/pipeline/tauri/ensure-signing-key-file.mjs @@ -0,0 +1,51 @@ +// @ts-check + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * @param {{ tmpRoot: string; keyValue: string; dryRun: boolean }} opts + * @returns {string} + */ +export function ensureTauriSigningKeyFile(opts) { + const raw = String(opts.keyValue ?? '').trim(); + if (!raw) return ''; + + const asPath = path.resolve(raw); + if (fs.existsSync(asPath) && fs.statSync(asPath).isFile()) { + return asPath; + } + + const looksLikePath = + raw.includes('/') || + raw.includes('\\') || + raw.startsWith('.') || + raw.endsWith('.key') || + raw.endsWith('.txt') || + raw.endsWith('.pem'); + const looksLikeInline = + raw.includes('\n') || raw.includes('\\n') || raw.startsWith('untrusted comment:') || raw.includes('BEGIN '); + + if (looksLikePath && !looksLikeInline) { + if (opts.dryRun) return asPath; + throw new Error(`TAURI_SIGNING_PRIVATE_KEY points at a missing file path: ${raw}`); + } + + const normalized = raw.includes('\\n') ? raw.replaceAll('\\n', '\n') : raw; + const withNewline = normalized.endsWith('\n') ? normalized : `${normalized}\n`; + + const tmpRoot = path.resolve(String(opts.tmpRoot ?? '').trim() || process.cwd()); + const outPath = path.join(tmpRoot, 'tauri.signing.key'); + + if (!opts.dryRun) { + fs.mkdirSync(tmpRoot, { recursive: true }); + fs.writeFileSync(outPath, withNewline, { encoding: 'utf8', mode: 0o600 }); + try { + fs.chmodSync(outPath, 0o600); + } catch { + // best effort + } + } + + return outPath; +} diff --git a/scripts/pipeline/tauri/notarize-macos-artifacts.mjs b/scripts/pipeline/tauri/notarize-macos-artifacts.mjs new file mode 100644 index 000000000..1bf64cb44 --- /dev/null +++ b/scripts/pipeline/tauri/notarize-macos-artifacts.mjs @@ -0,0 +1,242 @@ +// @ts-check + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; + +import { ensureTauriSigningKeyFile } from './ensure-signing-key-file.mjs'; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * @param {{ dryRun: boolean }} opts + * @param {string} cmd + * @param {string[]} args + * @param {{ cwd: string; env?: Record<string, string>; stdio?: import('node:child_process').StdioOptions; timeoutMs?: number }} extra + * @returns {string} + */ +function run(opts, cmd, args, extra) { + const printable = `${cmd} ${args.map((a) => (a.includes(' ') ? JSON.stringify(a) : a)).join(' ')}`; + if (opts.dryRun) { + console.log(`[dry-run] (cwd: ${extra.cwd}) ${printable}`); + return ''; + } + + return execFileSync(cmd, args, { + cwd: extra.cwd, + env: { ...process.env, ...(extra.env ?? {}) }, + encoding: 'utf8', + stdio: extra.stdio ?? 'inherit', + timeout: extra.timeoutMs ?? 30 * 60_000, + }); +} + +/** + * @param {string} dir + * @returns {string[]} + */ +function listFilesRecursive(dir) { + /** @type {string[]} */ + const out = []; + /** @type {string[]} */ + const stack = [dir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const abs = path.join(current, entry.name); + if (entry.isDirectory()) stack.push(abs); + else if (entry.isFile()) out.push(abs); + } + } + return out; +} + +/** + * @param {string} dir + * @param {string} filename + */ +function tempFile(dir, filename) { + fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, filename); +} + +function main() { + const repoRoot = path.resolve(process.cwd()); + const { values } = parseArgs({ + options: { + 'ui-dir': { type: 'string', default: 'apps/ui' }, + 'tauri-target': { type: 'string', default: '' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + const uiDir = String(values['ui-dir'] ?? '').trim() || 'apps/ui'; + const tauriTarget = String(values['tauri-target'] ?? '').trim(); + const dryRun = values['dry-run'] === true; + const opts = { dryRun }; + + const absUiDir = path.resolve(repoRoot, uiDir); + const baseDir = path.join(absUiDir, 'src-tauri', 'target'); + const searchDir = tauriTarget ? path.join(baseDir, tauriTarget) : baseDir; + + const tmpRoot = String(process.env.RUNNER_TEMP ?? '').trim() || os.tmpdir(); + const keyPath = tempFile(tmpRoot, 'apple-notary.p8'); + const signingKeyValue = String(process.env.TAURI_SIGNING_PRIVATE_KEY ?? '').trim(); + const signingKeyPath = signingKeyValue + ? ensureTauriSigningKeyFile({ tmpRoot, keyValue: signingKeyValue, dryRun: opts.dryRun }) + : ''; + + if (opts.dryRun) { + console.log(`[dry-run] search: ${path.relative(repoRoot, searchDir)}`); + } + + const appleKeyId = String(process.env.APPLE_API_KEY_ID ?? '').trim(); + const appleIssuerId = String(process.env.APPLE_API_ISSUER_ID ?? '').trim(); + const applePrivateKeyRaw = String(process.env.APPLE_API_PRIVATE_KEY ?? '').trim(); + if (!opts.dryRun) { + if (!appleKeyId || !appleIssuerId || !applePrivateKeyRaw) { + fail('APPLE_API_KEY_ID, APPLE_API_ISSUER_ID, and APPLE_API_PRIVATE_KEY are required to notarize macOS artifacts.'); + } + } + + if (opts.dryRun) { + console.log(`[dry-run] write ${keyPath} (Apple notary key)`); + } else { + const normalized = applePrivateKeyRaw.includes('\\n') ? applePrivateKeyRaw.replaceAll('\\n', '\n') : applePrivateKeyRaw; + if (normalized.includes('BEGIN PRIVATE KEY')) { + fs.writeFileSync(keyPath, normalized, 'utf8'); + } else { + fs.writeFileSync(keyPath, Buffer.from(normalized, 'base64')); + } + try { + fs.chmodSync(keyPath, 0o600); + } catch { + // best effort + } + } + + const files = opts.dryRun ? [] : listFilesRecursive(searchDir); + const sigMatches = files + .filter((p) => p.replaceAll(path.sep, '/').includes('/release/bundle/') && p.toLowerCase().endsWith('.app.tar.gz.sig')) + .sort((a, b) => a.localeCompare(b)); + + const sigPath = opts.dryRun ? path.join(searchDir, 'DRY_RUN.app.tar.gz.sig') : sigMatches[0]; + if (!opts.dryRun && sigMatches.length !== 1) { + fail(`Expected exactly one macOS updater signature under ${searchDir}; found ${sigMatches.length}`); + } + + const artifactPath = sigPath.endsWith('.sig') ? sigPath.slice(0, -'.sig'.length) : sigPath; + + if (!opts.dryRun) { + if (!fs.existsSync(artifactPath) || !fs.statSync(artifactPath).isFile()) { + fail(`Missing updater artifact for signature: ${sigPath}`); + } + } + + const workDir = opts.dryRun ? path.join(tmpRoot, 'DRY_RUN_WORK') : fs.mkdtempSync(path.join(tmpRoot, 'happier-tauri-notary-')); + const zipPath = path.join(workDir, 'app.zip'); + + run(opts, 'tar', ['-xzf', artifactPath, '-C', workDir], { cwd: absUiDir, timeoutMs: 10 * 60_000 }); + + const appPath = opts.dryRun ? path.join(workDir, 'Happier.app') : findAppDir(workDir); + run(opts, 'ditto', ['-c', '-k', '--keepParent', appPath, zipPath], { cwd: absUiDir, timeoutMs: 10 * 60_000 }); + + run( + opts, + 'xcrun', + ['notarytool', 'submit', zipPath, '--key', keyPath, '--key-id', appleKeyId || 'DRY_RUN', '--issuer', appleIssuerId || 'DRY_RUN', '--wait', '--timeout', '15m'], + { cwd: absUiDir, timeoutMs: 30 * 60_000 }, + ); + run(opts, 'xcrun', ['stapler', 'staple', appPath], { cwd: absUiDir, timeoutMs: 10 * 60_000 }); + + const appName = path.basename(appPath); + const appParent = path.dirname(appPath); + const newTar = path.join(workDir, 'notarized.app.tar.gz'); + run(opts, 'tar', ['-czf', newTar, '-C', appParent, appName], { cwd: absUiDir, timeoutMs: 10 * 60_000 }); + + if (opts.dryRun) { + console.log(`[dry-run] mv ${newTar} -> ${artifactPath}`); + } else { + fs.renameSync(newTar, artifactPath); + } + + const sigValue = run( + opts, + 'yarn', + ['--silent', 'tauri', 'signer', 'sign', path.resolve(absUiDir, artifactPath)], + { + cwd: absUiDir, + env: { + ...(signingKeyPath ? { TAURI_SIGNING_PRIVATE_KEY: signingKeyPath } : {}), + }, + stdio: ['ignore', 'pipe', 'inherit'], + timeoutMs: 10 * 60_000, + }, + ) + .trim() + .replaceAll('\r', '') + .replaceAll('\n', ''); + + if (opts.dryRun) { + console.log(`[dry-run] write ${sigPath} (updated signature)`); + } else { + if (!sigValue || !/^[A-Za-z0-9+/=]+$/.test(sigValue)) { + fail(`Generated updater signature is invalid (got ${sigValue.length} chars).`); + } + fs.writeFileSync(sigPath, `${sigValue}\n`, 'utf8'); + } + + const dmgCandidates = files + .filter((p) => p.replaceAll(path.sep, '/').includes('/release/bundle/') && p.toLowerCase().endsWith('.dmg')) + .sort((a, b) => a.localeCompare(b)); + const dmgPath = opts.dryRun ? path.join(searchDir, 'DRY_RUN.dmg') : dmgCandidates[0]; + if (dmgCandidates.length > 0 || opts.dryRun) { + run( + opts, + 'xcrun', + ['notarytool', 'submit', dmgPath, '--key', keyPath, '--key-id', appleKeyId || 'DRY_RUN', '--issuer', appleIssuerId || 'DRY_RUN', '--wait', '--timeout', '15m'], + { cwd: absUiDir, timeoutMs: 30 * 60_000 }, + ); + run(opts, 'xcrun', ['stapler', 'staple', dmgPath], { cwd: absUiDir, timeoutMs: 10 * 60_000 }); + } +} + +/** + * @param {string} workDir + */ +function findAppDir(workDir) { + /** @type {string[]} */ + const stack = [workDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const abs = path.join(current, entry.name); + if (entry.name.endsWith('.app')) return abs; + stack.push(abs); + } + } + fail(`Unable to find .app inside updater artifact (work dir: ${workDir})`); +} + +main(); diff --git a/scripts/release/build_tauri_artifact_names.contract.test.mjs b/scripts/release/build_tauri_artifact_names.contract.test.mjs index db98fb26b..bb98f6766 100644 --- a/scripts/release/build_tauri_artifact_names.contract.test.mjs +++ b/scripts/release/build_tauri_artifact_names.contract.test.mjs @@ -6,11 +6,13 @@ import path from 'node:path'; const repoRoot = path.resolve(import.meta.dirname, '..', '..'); test('build-tauri workflow names updater assets as happier-ui-desktop-*', () => { - const src = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'build-tauri.yml'), 'utf8'); + const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'build-tauri.yml'), 'utf8'); + assert.match(workflow, /node scripts\/pipeline\/run\.mjs tauri-collect-updater-artifacts/); - assert.match(src, /out_base="happier-ui-desktop-preview-\$\{PLATFORM_KEY\}"/); - assert.match(src, /out_base="happier-ui-desktop-\$\{PLATFORM_KEY\}-v\$\{UI_VERSION\}"/); + const script = fs.readFileSync(path.join(repoRoot, 'scripts', 'pipeline', 'tauri', 'collect-updater-artifacts.mjs'), 'utf8'); + assert.match(script, /happier-ui-desktop-preview-\$\{platformKey\}/); + assert.match(script, /happier-ui-desktop-\$\{platformKey\}-v\$\{uiVersion\}/); - assert.doesNotMatch(src, /out_base="happier-ui-preview-/); - assert.doesNotMatch(src, /out_base="happier-ui-\$\{PLATFORM_KEY\}-v/); + assert.doesNotMatch(script, /happier-ui-preview-/); + assert.doesNotMatch(script, /happier-ui-\$\{platformKey\}-v/); }); diff --git a/scripts/release/build_tauri_release_tags.workflow.contract.test.mjs b/scripts/release/build_tauri_release_tags.workflow.contract.test.mjs index 37e1b014a..01386296d 100644 --- a/scripts/release/build_tauri_release_tags.workflow.contract.test.mjs +++ b/scripts/release/build_tauri_release_tags.workflow.contract.test.mjs @@ -30,7 +30,7 @@ test('build-tauri publishes desktop releases under ui-desktop-* tags', async () test('build-tauri latest.json generator uses ui-desktop-* release tags and publish assets are namespaced', async () => { const raw = await loadWorkflow('build-tauri.yml'); - assert.match(raw, /node scripts\/pipeline\/tauri\/prepare-publish-assets\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs tauri-prepare-assets/); const script = await loadFile('scripts/pipeline/tauri/prepare-publish-assets.mjs'); assert.match(script, /ui-desktop-preview/); diff --git a/scripts/release/build_tauri_workflow.production_signing_gate.test.mjs b/scripts/release/build_tauri_workflow.production_signing_gate.test.mjs index 0b0a00b84..d07feed2c 100644 --- a/scripts/release/build_tauri_workflow.production_signing_gate.test.mjs +++ b/scripts/release/build_tauri_workflow.production_signing_gate.test.mjs @@ -9,6 +9,10 @@ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..', '..'); const workflowPath = join(repoRoot, '.github', 'workflows', 'build-tauri.yml'); +async function loadFile(rel) { + return readFile(join(repoRoot, rel), 'utf8'); +} + test('production macOS tauri workflow hard-fails when signing/notarization secrets are missing', async () => { const workflow = await readFile(workflowPath, 'utf8'); const parsed = parse(workflow); @@ -103,19 +107,22 @@ test('build-tauri workflow avoids escaped quote JS snippets and captures Apple i const buildScript = String(tauriBuildStep?.run ?? ''); assert.match( buildScript, - /rustup target add "\$\{TAURI_TARGET\}"/, - 'desktop build should ensure TAURI_TARGET is installed before invoking tauri build' + /node scripts\/pipeline\/run\.mjs tauri-build-updater-artifacts/, + 'desktop build should delegate to the pipeline command (no direct leaf script call)' ); + assert.match(buildScript, /--tauri-target/, 'desktop build should pass --tauri-target through to pipeline script'); + const buildPipelineScript = await loadFile('scripts/pipeline/tauri/build-updater-artifacts.mjs'); + assert.match(buildPipelineScript, /\brustup\b/, 'pipeline build script should install the tauri rust target when provided'); assert.match( - buildScript, + buildPipelineScript, /createUpdaterArtifacts/, - 'desktop build should explicitly enable updater artifacts in CI (base config defaults to disabled for local builds)' + 'pipeline build script should enable updater artifacts when TAURI_SIGNING_PRIVATE_KEY is available' ); assert.match( - buildScript, + buildPipelineScript, /TAURI_SIGNING_PRIVATE_KEY/, - 'desktop build should gate updater artifact generation on the presence of TAURI_SIGNING_PRIVATE_KEY' + 'pipeline build script should gate updater artifact generation on TAURI_SIGNING_PRIVATE_KEY' ); const collectStep = buildSteps.find( @@ -125,9 +132,11 @@ test('build-tauri workflow avoids escaped quote JS snippets and captures Apple i const collectScript = String(collectStep.run ?? ''); assert.match( collectScript, - /\*\.AppImage\.sig/, - 'linux updater collection should match AppImage signature files emitted by tauri' + /node scripts\/pipeline\/run\.mjs tauri-collect-updater-artifacts/, + 'updater collection should delegate to the pipeline command' ); + const collectPipelineScript = await loadFile('scripts/pipeline/tauri/collect-updater-artifacts.mjs'); + assert.match(collectPipelineScript, /\.appimage\.sig/, 'linux updater collection should match appimage signature files'); const notarizeStep = buildSteps.find( (step) => step?.name === 'Notarize macOS artifacts (updater + DMG) (macOS)' @@ -136,8 +145,15 @@ test('build-tauri workflow avoids escaped quote JS snippets and captures Apple i const notarizeScript = String(notarizeStep.run ?? ''); assert.match( notarizeScript, - /replaceAll\("\\\\n", "\\n"\)|replaceAll\('\\\\n', '\\n'\)/, - 'notarization should normalize escaped newline private key secrets before writing the key file' + /node scripts\/pipeline\/run\.mjs tauri-notarize-macos-artifacts/, + 'notarization should delegate to the pipeline command' + ); + + const notarizePipelineScript = await loadFile('scripts/pipeline/tauri/notarize-macos-artifacts.mjs'); + assert.match( + notarizePipelineScript, + /replaceAll\('\\\\n', '\\n'\)|replaceAll\(\"\\\\n\", \"\\n\"\)/, + 'notarization script should normalize escaped newline private key secrets before writing the key file' ); }); @@ -153,8 +169,8 @@ test('build-tauri workflow validates updater pubkey via pipeline script', async const runScript = String(step.run ?? ''); assert.match( runScript, - /node scripts\/pipeline\/tauri\/validate-updater-pubkey\.mjs/, - 'workflow should delegate updater pubkey validation to pipeline script (no inline heredoc)', + /node scripts\/pipeline\/run\.mjs tauri-validate-updater-pubkey/, + 'workflow should delegate updater pubkey validation to the pipeline command (no inline heredoc)', ); assert.doesNotMatch( runScript, diff --git a/scripts/release/checks_profile_plan.contract.test.mjs b/scripts/release/checks_profile_plan.contract.test.mjs new file mode 100644 index 000000000..c18ba156d --- /dev/null +++ b/scripts/release/checks_profile_plan.contract.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { resolveChecksProfilePlan } from '../pipeline/checks/lib/checks-profile.mjs'; + +test('checks profile: none disables CI checks', () => { + const plan = resolveChecksProfilePlan({ profile: 'none', customChecks: '' }); + assert.equal(plan.runCi, false); + assert.equal(plan.runUiE2e, false); + assert.equal(plan.runE2eCore, false); + assert.equal(plan.runE2eCoreSlow, false); + assert.equal(plan.runServerDbContract, false); + assert.equal(plan.runStress, false); + assert.equal(plan.runBuildWebsite, false); + assert.equal(plan.runBuildDocs, false); + assert.equal(plan.runCliSmokeLinux, false); +}); + +test('checks profile: fast runs CI but skips slow lanes', () => { + const plan = resolveChecksProfilePlan({ profile: 'fast', customChecks: 'e2e_core,stress,build_website' }); + assert.equal(plan.runCi, true); + assert.equal(plan.runUiE2e, true); + assert.equal(plan.runE2eCore, false); + assert.equal(plan.runE2eCoreSlow, false); + assert.equal(plan.runServerDbContract, false); + assert.equal(plan.runStress, false); + assert.equal(plan.runBuildWebsite, false); + assert.equal(plan.runBuildDocs, false); + assert.equal(plan.runCliSmokeLinux, false); +}); + +test('checks profile: full enables e2e/db-contract/builds/smoke', () => { + const plan = resolveChecksProfilePlan({ profile: 'full', customChecks: '' }); + assert.equal(plan.runCi, true); + assert.equal(plan.runUiE2e, true); + assert.equal(plan.runE2eCore, true); + assert.equal(plan.runE2eCoreSlow, true); + assert.equal(plan.runServerDbContract, true); + assert.equal(plan.runStress, false); + assert.equal(plan.runBuildWebsite, true); + assert.equal(plan.runBuildDocs, true); + assert.equal(plan.runCliSmokeLinux, true); +}); + +test('checks profile: custom toggles lanes from CSV', () => { + const plan = resolveChecksProfilePlan({ + profile: 'custom', + customChecks: 'e2e_core_slow,server_db_contract,build_docs,cli_smoke_linux,stress', + }); + assert.equal(plan.runCi, true); + assert.equal(plan.runUiE2e, false); + assert.equal(plan.runE2eCore, true); + assert.equal(plan.runE2eCoreSlow, true); + assert.equal(plan.runServerDbContract, true); + assert.equal(plan.runStress, true); + assert.equal(plan.runBuildWebsite, false); + assert.equal(plan.runBuildDocs, true); + assert.equal(plan.runCliSmokeLinux, true); +}); + +test('checks profile: custom with e2e_core enables fast e2e only', () => { + const plan = resolveChecksProfilePlan({ profile: 'custom', customChecks: 'e2e_core' }); + assert.equal(plan.runCi, true); + assert.equal(plan.runUiE2e, false); + assert.equal(plan.runE2eCore, true); + assert.equal(plan.runE2eCoreSlow, false); +}); + +test('checks profile: custom ui e2e toggle', () => { + const plan = resolveChecksProfilePlan({ profile: 'custom', customChecks: 'ui_e2e' }); + assert.equal(plan.runCi, true); + assert.equal(plan.runUiE2e, true); +}); diff --git a/scripts/release/deploy_workflow_push_caller.contract.test.mjs b/scripts/release/deploy_workflow_push_caller.contract.test.mjs new file mode 100644 index 000000000..ad962fb56 --- /dev/null +++ b/scripts/release/deploy_workflow_push_caller.contract.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('deploy workflow accepts push as caller event (via workflow_call)', async () => { + const workflowPath = resolve(repoRoot, '.github', 'workflows', 'deploy.yml'); + const raw = fs.readFileSync(workflowPath, 'utf8'); + + // Called workflows inherit `github.event_name` from the caller (e.g. push), so deploy.yml must not reject it. + assert.match(raw, /Unsupported event for deploy workflow/, 'deploy.yml should keep a clear error message for unexpected events'); + assert.match(raw, /workflow_dispatch/, 'deploy.yml should still accept workflow_dispatch'); + assert.match(raw, /workflow_call/, 'deploy.yml should still accept workflow_call'); + assert.match(raw, /\bpush\b/, 'deploy.yml should accept push when invoked from a push-triggered caller workflow'); +}); + diff --git a/scripts/release/deploy_workflow_uses_trigger_webhooks_script.contract.test.mjs b/scripts/release/deploy_workflow_uses_trigger_webhooks_script.contract.test.mjs index f3a9426e8..25c248cce 100644 --- a/scripts/release/deploy_workflow_uses_trigger_webhooks_script.contract.test.mjs +++ b/scripts/release/deploy_workflow_uses_trigger_webhooks_script.contract.test.mjs @@ -7,11 +7,11 @@ import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..', '..'); -test('deploy workflow delegates webhook triggering to scripts/pipeline/deploy/trigger-webhooks.mjs', async () => { +test('deploy workflow delegates webhook triggering to the pipeline deploy command', async () => { const workflowPath = join(repoRoot, '.github', 'workflows', 'deploy.yml'); const raw = await readFile(workflowPath, 'utf8'); - assert.match(raw, /node scripts\/pipeline\/deploy\/trigger-webhooks\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs deploy/); assert.doesNotMatch( raw, /curl\s+-sS\s+-X\s+POST/, diff --git a/scripts/release/docker_publish.workflow.contract.test.mjs b/scripts/release/docker_publish.workflow.contract.test.mjs index ca9dbb791..ea9cbe7f1 100644 --- a/scripts/release/docker_publish.workflow.contract.test.mjs +++ b/scripts/release/docker_publish.workflow.contract.test.mjs @@ -14,18 +14,74 @@ async function loadWorkflow(name) { test('publish-docker supports workflow_call and is wired from release workflow', async () => { const publishDocker = await loadWorkflow('publish-docker.yml'); assert.match(publishDocker, /\n\s*workflow_call:\n/); + assert.match( + publishDocker, + /permissions:\n\s+contents:\s+read\n\s+packages:\s+write/m, + 'publish-docker should request packages:write for GHCR pushes', + ); assert.match(publishDocker, /\n\s*source_ref:\n/); + assert.match( + publishDocker, + /\n\s*registries:\n/, + 'publish-docker should support configuring which registries receive image pushes', + ); assert.match(publishDocker, /\n\s*build_relay:\n/); - assert.match(publishDocker, /\n\s*build_devcontainer:\n/); + assert.match(publishDocker, /\n\s*build_dev_box:\n/); + assert.match( + publishDocker, + /node scripts\/pipeline\/run\.mjs docker-publish/, + 'publish-docker should delegate docker build+push to the pipeline docker-publish command', + ); + assert.match(publishDocker, /--registries "\${{\s*inputs\.registries\s*}}"/); + assert.match( + publishDocker, + /DOCKERHUB_USERNAME:\s*\${{\s*secrets\.DOCKERHUB_USERNAME\s*}}/, + 'publish-docker should pass Docker Hub username to the pipeline script', + ); + assert.match( + publishDocker, + /DOCKERHUB_TOKEN:\s*\${{\s*secrets\.DOCKERHUB_TOKEN\s*}}/, + 'publish-docker should pass Docker Hub token to the pipeline script', + ); + assert.match( + publishDocker, + /Login to GHCR/, + 'publish-docker should login to GHCR (ghcr.io)', + ); + assert.match( + publishDocker, + /registry:\s*ghcr\.io/, + 'publish-docker should use docker/login-action registry ghcr.io', + ); + assert.match( + publishDocker, + /peter-evans\/dockerhub-description@/, + 'publish-docker should publish Docker Hub README/description', + ); + assert.match( + publishDocker, + /repository:\s*happierdev\/relay-server/, + 'publish-docker should publish relay-server Docker Hub README', + ); + assert.match( + publishDocker, + /readme-filepath:\s*docker\/dockerhub\/relay-server\.md/, + 'publish-docker should use repo README file for relay-server', + ); + assert.match( + publishDocker, + /repository:\s*happierdev\/dev-box/, + 'publish-docker should publish dev-box Docker Hub README', + ); assert.match( publishDocker, - /node scripts\/pipeline\/docker\/publish-images\.mjs/, - 'publish-docker should delegate docker build+push to the pipeline script', + /readme-filepath:\s*docker\/dockerhub\/dev-box\.md/, + 'publish-docker should use repo README file for dev-box', ); const release = await loadWorkflow('release.yml'); assert.match(release, /publish_docker:/); assert.match(release, /uses:\s+\.\/\.github\/workflows\/publish-docker\.yml/); assert.match(release, /build_relay:/); - assert.match(release, /build_devcontainer:/); + assert.match(release, /build_dev_box:/); }); diff --git a/scripts/release/eas_local_build_env.contract.test.mjs b/scripts/release/eas_local_build_env.contract.test.mjs new file mode 100644 index 000000000..3182c87d7 --- /dev/null +++ b/scripts/release/eas_local_build_env.contract.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createEasLocalBuildEnv } from '../pipeline/expo/eas-local-build-env.mjs'; + +test('EAS local builds disable expo doctor step by default', () => { + const env = createEasLocalBuildEnv({ baseEnv: {}, platform: 'ios' }); + assert.equal(env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP, '1'); + assert.equal(env.FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT, '30'); +}); + +test('EAS local builds do not override explicit expo doctor setting', () => { + const env = createEasLocalBuildEnv({ + baseEnv: { EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP: '0', FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: '5' }, + platform: 'ios', + }); + assert.equal(env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP, '0'); + assert.equal(env.FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT, '5'); +}); + +test('EAS local builds do not set fastlane xcode settings timeout for android', () => { + const env = createEasLocalBuildEnv({ baseEnv: {}, platform: 'android' }); + assert.equal(env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP, '1'); + assert.ok(!('FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT' in env)); +}); + +test('EAS local iOS builds reorder PATH so /usr/bin precedes /opt/homebrew/bin (rsync compatibility)', () => { + const baseEnv = { + PATH: '/Users/leeroy/.nvm/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin', + }; + const env = createEasLocalBuildEnv({ baseEnv, platform: 'ios' }); + assert.equal( + env.PATH, + '/Users/leeroy/.nvm/bin:/usr/bin:/opt/homebrew/bin:/bin:/usr/sbin:/sbin', + ); +}); + +test('EAS local iOS builds do not reorder PATH when /usr/bin already precedes /opt/homebrew/bin', () => { + const baseEnv = { + PATH: '/Users/leeroy/.nvm/bin:/usr/bin:/opt/homebrew/bin:/bin', + }; + const env = createEasLocalBuildEnv({ baseEnv, platform: 'ios' }); + assert.equal(env.PATH, baseEnv.PATH); +}); diff --git a/scripts/release/gh_release_edit_args.contract.test.mjs b/scripts/release/gh_release_edit_args.contract.test.mjs new file mode 100644 index 000000000..56234a65b --- /dev/null +++ b/scripts/release/gh_release_edit_args.contract.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildRollingReleaseEditArgs } from '../pipeline/github/lib/gh-release-commands.mjs'; + +test('rolling release edit args include --target sha', () => { + const args = buildRollingReleaseEditArgs({ + tag: 'cli-preview', + title: 'Happier CLI Preview', + notes: 'Rolling preview', + targetSha: 'deadbeef', + }); + assert.deepEqual(args.slice(0, 3), ['release', 'edit', 'cli-preview']); + assert.ok(args.includes('--target')); + assert.ok(args.includes('deadbeef')); +}); + diff --git a/scripts/release/installers/install.sh b/scripts/release/installers/install.sh index 29ced9f28..0ac14bd40 100644 --- a/scripts/release/installers/install.sh +++ b/scripts/release/installers/install.sh @@ -8,6 +8,9 @@ BIN_DIR="${HAPPIER_BIN_DIR:-$HOME/.local/bin}" WITH_DAEMON="${HAPPIER_WITH_DAEMON:-1}" NO_PATH_UPDATE="${HAPPIER_NO_PATH_UPDATE:-0}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_INSTALL_DIR="${HAPPIER_INSTALLER_PURGE:-0}" GITHUB_REPO="${HAPPIER_GITHUB_REPO:-happier-dev/happier}" GITHUB_TOKEN="${HAPPIER_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}" DEFAULT_MINISIGN_PUBKEY="$(cat <<'EOF' @@ -19,87 +22,60 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" -usage() { - cat <<'EOF' -Usage: - curl -fsSL https://happier.dev/install | bash +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never -Preview channel: - curl -fsSL https://happier.dev/install | bash -s -- --channel preview - curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash - curl -fsSL https://happier.dev/install-preview | bash +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} -Options: - --channel <stable|preview> - --stable - --preview - --with-daemon - --without-daemon - -h, --help -EOF +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" } -while [[ $# -gt 0 ]]; do - case "$1" in - --channel) - if [[ $# -lt 2 || -z "${2:-}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - CHANNEL="${2}" - shift 2 - ;; - --channel=*) - CHANNEL="${1#*=}" - if [[ -z "${CHANNEL}" ]]; then - echo "Missing value for --channel" >&2 - usage >&2 - exit 1 - fi - shift 1 - ;; - --stable) - CHANNEL="stable" - shift 1 - ;; - --preview) - CHANNEL="preview" - shift 1 - ;; - --with-daemon) - WITH_DAEMON="1" - shift 1 - ;; - --without-daemon) - WITH_DAEMON="0" - shift 1 - ;; - -h|--help) - usage - exit 0 - ;; - --) - shift 1 - break - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} -if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then - echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 - exit 1 -fi +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} -if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then - echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 - exit 1 -fi +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +shell_command_cache_hint() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + if [[ "${shell_name}" == "zsh" ]]; then + say " rehash" + else + say " hash -r" + fi +} detect_os() { case "$(uname -s)" in @@ -175,6 +151,387 @@ json_lookup_asset_url() { ' } +resolve_exe_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "happier-server" + else + echo "happier" + fi +} + +resolve_install_name() { + if [[ "${PRODUCT}" == "server" ]]; then + echo "Happier Server" + else + echo "Happier CLI" + fi +} + +resolve_installed_binary() { + local exe + exe="$(resolve_exe_name)" + local candidate="${INSTALL_DIR}/bin/${exe}" + if [[ -x "${candidate}" ]]; then + printf '%s' "${candidate}" + return 0 + fi + local from_path + from_path="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${from_path}" ]] && [[ -x "${from_path}" ]]; then + printf '%s' "${from_path}" + return 0 + fi + return 1 +} + +action_check() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local ok="1" + local binary_path="${INSTALL_DIR}/bin/${exe}" + local shim_path="${BIN_DIR}/${exe}" + + info "${name} check" + say "- product: ${PRODUCT}" + say "- binary: ${binary_path}" + say "- shim: ${shim_path}" + + if [[ ! -x "${binary_path}" ]]; then + warn "Missing binary: ${binary_path}" + ok="0" + fi + + if [[ ! -e "${shim_path}" ]]; then + warn "Missing shim: ${shim_path}" + fi + + local resolved="" + resolved="$(command -v "${exe}" 2>/dev/null || true)" + if [[ -n "${resolved}" ]]; then + say "- command: ${resolved}" + else + warn "Command not found on PATH: ${exe}" + fi + + local resolved_binary="" + resolved_binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${resolved_binary}" ]]; then + local version_out="" + version_out="$("${resolved_binary}" --version 2>/dev/null || true)" + if [[ -n "${version_out}" ]]; then + say "- version: ${version_out}" + else + warn "Failed to execute: ${resolved_binary}" + ok="0" + fi + fi + + if command -v file >/dev/null 2>&1 && [[ -x "${binary_path}" ]]; then + say + say "file:" + file "${binary_path}" || true + fi + if command -v xattr >/dev/null 2>&1 && [[ -e "${binary_path}" ]]; then + say + say "xattr:" + xattr -l "${binary_path}" 2>/dev/null || true + fi + + say + say "Shell tip (if PATH changed in this session):" + shell_command_cache_hint + + if [[ "${ok}" == "1" ]]; then + success "OK" + return 0 + fi + warn "${name} is not installed correctly." + return 1 +} + +action_restart() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -z "${binary}" ]]; then + warn "${name} is not installed." + return 1 + fi + if [[ "${PRODUCT}" != "cli" ]]; then + warn "Restart is only supported for the CLI daemon." + return 1 + fi + + info "Restarting daemon service (best-effort)..." + if ! "${binary}" daemon service restart >/dev/null 2>&1; then + warn "Daemon service restart failed (it may not be installed)." + warn "Try: ${binary} daemon service install" + return 1 + fi + success "Daemon service restarted." + return 0 +} + +action_uninstall() { + local exe + exe="$(resolve_exe_name)" + local name + name="$(resolve_install_name)" + + local binary="" + binary="$(resolve_installed_binary 2>/dev/null || true)" + if [[ -n "${binary}" && "${PRODUCT}" == "cli" ]]; then + "${binary}" daemon service uninstall >/dev/null 2>&1 || true + fi + + rm -f "${BIN_DIR}/${exe}" "${INSTALL_DIR}/bin/${exe}.new" "${INSTALL_DIR}/bin/${exe}.previous" || true + rm -f "${INSTALL_DIR}/bin/${exe}" || true + if [[ "${PURGE_INSTALL_DIR}" == "1" ]]; then + rm -rf "${INSTALL_DIR}" || true + fi + + success "${name} uninstalled." + say "Tip: if your shell still can't find changes, run:" + shell_command_cache_hint + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + +action_version() { + local name + name="$(resolve_install_name)" + + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local os="" + local arch="" + os="$(detect_os)" + arch="$(detect_arch)" + if [[ "${os}" == "unsupported" || "${arch}" == "unsupported" ]]; then + echo "Unsupported platform: $(uname -s)/$(uname -m)" >&2 + return 1 + fi + + local tag="cli-stable" + local asset_regex="^happier-v.*-${os}-${arch}[.]tar[.]gz$" + local version_prefix="happier-v" + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-stable" + asset_regex="^happier-server-v.*-${os}-${arch}[.]tar[.]gz$" + version_prefix="happier-server-v" + fi + if [[ "${CHANNEL}" == "preview" ]]; then + if [[ "${PRODUCT}" == "server" ]]; then + tag="server-preview" + else + tag="cli-preview" + fi + fi + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + curl_auth() { + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + curl -fsSL \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + return + fi + curl -fsSL "$@" + } + + local release_json="" + if ! release_json="$(curl_auth "${api_url}")"; then + echo "Failed to fetch release metadata for ${name}." >&2 + return 1 + fi + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${OS}-${ARCH} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#${version_prefix}}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "${name} installer version check" + say "- channel: ${CHANNEL}" + say "- product: ${PRODUCT}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +usage() { + cat <<'EOF' +Usage: + curl -fsSL https://happier.dev/install | bash + +Preview channel: + curl -fsSL https://happier.dev/install | bash -s -- --channel preview + curl -fsSL https://happier.dev/install | HAPPIER_CHANNEL=preview bash + curl -fsSL https://happier.dev/install-preview | bash + +Options: + --channel <stable|preview> + --stable + --preview + --with-daemon + --without-daemon + --check + --version + --reinstall + --restart + --uninstall [--purge] + --reset + --debug + -h, --help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --channel) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + CHANNEL="${2}" + shift 2 + ;; + --channel=*) + CHANNEL="${1#*=}" + if [[ -z "${CHANNEL}" ]]; then + echo "Missing value for --channel" >&2 + usage >&2 + exit 1 + fi + shift 1 + ;; + --stable) + CHANNEL="stable" + shift 1 + ;; + --preview) + CHANNEL="preview" + shift 1 + ;; + --with-daemon) + WITH_DAEMON="1" + shift 1 + ;; + --without-daemon) + WITH_DAEMON="0" + shift 1 + ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --purge) + PURGE_INSTALL_DIR="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift 1 + break + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + +if [[ "${PRODUCT}" != "cli" && "${PRODUCT}" != "server" ]]; then + echo "Invalid HAPPIER_PRODUCT='${PRODUCT}'. Expected cli or server." >&2 + exit 1 +fi + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi + +if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -225,7 +582,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else # Prefer built-in macOS tooling to avoid requiring unzip. if command -v ditto >/dev/null 2>&1; then @@ -292,16 +649,58 @@ append_path_hint() { fi local shell_name shell_name="$(basename "${SHELL:-}")" - local rc_file + local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" + local rc_files=() case "${shell_name}" in - zsh) rc_file="$HOME/.zshrc" ;; - bash) rc_file="$HOME/.bashrc" ;; - *) rc_file="$HOME/.profile" ;; + zsh) + rc_files+=("$HOME/.zshrc") + rc_files+=("$HOME/.zprofile") + ;; + bash) + rc_files+=("$HOME/.bashrc") + if [[ -f "$HOME/.bash_profile" ]]; then + rc_files+=("$HOME/.bash_profile") + else + rc_files+=("$HOME/.profile") + fi + ;; + *) + rc_files+=("$HOME/.profile") + ;; esac - local export_line="export PATH=\"${BIN_DIR}:\$PATH\"" - if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then - printf '\n%s\n' "${export_line}" >> "${rc_file}" - echo "Added ${BIN_DIR} to PATH in ${rc_file}" + + local updated=0 + for rc_file in "${rc_files[@]}"; do + if [[ ! -f "${rc_file}" ]] || ! grep -Fq "${export_line}" "${rc_file}"; then + printf '\n%s\n' "${export_line}" >> "${rc_file}" + info "Added ${BIN_DIR} to PATH in ${rc_file}" + updated=1 + fi + done + + if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then + echo + say "${COLOR_BOLD}Next steps${COLOR_RESET}" + say "To use ${EXE_NAME} in your current shell:" + say " export PATH=\"${BIN_DIR}:\$PATH\"" + if [[ "${shell_name}" == "bash" ]]; then + say " source \"$HOME/.bashrc\"" + if [[ -f "$HOME/.bash_profile" ]]; then + say " source \"$HOME/.bash_profile\"" + else + say " source \"$HOME/.profile\"" + fi + elif [[ "${shell_name}" == "zsh" ]]; then + say " source \"$HOME/.zshrc\"" + else + say " source \"$HOME/.profile\"" + fi + say "If your shell still can't find ${EXE_NAME}, run:" + shell_command_cache_hint + say "Or open a new terminal." + elif [[ "${updated}" == "1" ]]; then + echo + say "PATH is already configured in this shell." fi } @@ -350,7 +749,7 @@ if [[ "${CHANNEL}" == "preview" ]]; then fi API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." curl_auth() { if [[ -n "${GITHUB_TOKEN:-}" ]]; then curl -fsSL \ @@ -396,6 +795,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -415,7 +817,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -428,11 +830,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl_auth -o "${SIG_PATH}" "${SIG_URL}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name "${EXE_NAME}" -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then @@ -441,15 +843,25 @@ if [[ -z "${BINARY_PATH}" ]]; then fi mkdir -p "${INSTALL_DIR}/bin" "${BIN_DIR}" -cp "${BINARY_PATH}" "${INSTALL_DIR}/bin/${EXE_NAME}" -chmod +x "${INSTALL_DIR}/bin/${EXE_NAME}" +TARGET_BIN="${INSTALL_DIR}/bin/${EXE_NAME}" +STAGED_BIN="${TARGET_BIN}.new" +PREVIOUS_BIN="${TARGET_BIN}.previous" +cp "${BINARY_PATH}" "${STAGED_BIN}" +chmod +x "${STAGED_BIN}" +if [[ -f "${TARGET_BIN}" ]]; then + cp "${TARGET_BIN}" "${PREVIOUS_BIN}" >/dev/null 2>&1 || true + chmod +x "${PREVIOUS_BIN}" >/dev/null 2>&1 || true +fi +# Avoid ETXTBSY when replacing a running executable: swap the directory entry atomically. +mv -f "${STAGED_BIN}" "${TARGET_BIN}" +chmod +x "${TARGET_BIN}" ln -sf "${INSTALL_DIR}/bin/${EXE_NAME}" "${BIN_DIR}/${EXE_NAME}" append_path_hint if [[ "${PRODUCT}" == "cli" && "${WITH_DAEMON}" == "1" ]]; then echo - echo "Installing daemon service (user-mode)..." + info "Installing daemon service (user-mode)..." if ! "${INSTALL_DIR}/bin/${EXE_NAME}" daemon service install >/dev/null 2>&1; then echo "Warning: daemon service install failed. You can retry manually:" >&2 echo " ${INSTALL_DIR}/bin/${EXE_NAME} daemon service install" >&2 diff --git a/scripts/release/installers/self-host.sh b/scripts/release/installers/self-host.sh index bf4666e55..8838e036f 100644 --- a/scripts/release/installers/self-host.sh +++ b/scripts/release/installers/self-host.sh @@ -9,6 +9,9 @@ if [[ -n "${HAPPIER_SELF_HOST_MODE:-}" ]]; then fi WITH_CLI="${HAPPIER_WITH_CLI:-1}" NONINTERACTIVE="${HAPPIER_NONINTERACTIVE:-0}" +ACTION="${HAPPIER_INSTALLER_ACTION:-install}" # install|reinstall|version|check|uninstall|restart +DEBUG_MODE="${HAPPIER_INSTALLER_DEBUG:-0}" +PURGE_DATA="${HAPPIER_SELF_HOST_PURGE_DATA:-0}" HAPPIER_HOME="${HAPPIER_HOME:-${HOME}/.happier}" STACK_INSTALL_DIR="${HAPPIER_STACK_INSTALL_ROOT:-}" STACK_BIN_DIR="${HAPPIER_STACK_BIN_DIR:-}" @@ -22,6 +25,187 @@ MINISIGN_PUBKEY="${HAPPIER_MINISIGN_PUBKEY:-${DEFAULT_MINISIGN_PUBKEY}}" MINISIGN_PUBKEY_URL="${HAPPIER_MINISIGN_PUBKEY_URL:-https://happier.dev/happier-release.pub}" MINISIGN_BIN="minisign" +INSTALLER_COLOR_MODE="${HAPPIER_INSTALLER_COLOR:-auto}" # auto|always|never + +supports_color() { + if [[ "${INSTALLER_COLOR_MODE}" == "never" ]]; then + return 1 + fi + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${INSTALLER_COLOR_MODE}" == "always" ]]; then + return 0 + fi + [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]] +} + +if supports_color; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_CYAN=$'\033[36m' +else + COLOR_RESET="" + COLOR_BOLD="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_CYAN="" +fi + +say() { + printf '%s\n' "$*" +} + +info() { + say "${COLOR_CYAN}$*${COLOR_RESET}" +} + +success() { + say "${COLOR_GREEN}$*${COLOR_RESET}" +} + +warn() { + say "${COLOR_YELLOW}$*${COLOR_RESET}" +} + +json_lookup_asset_url() { + local json="$1" + local name_regex="$2" + # GitHub API JSON is typically pretty-printed (newlines + spaces). Avoid "minifying" into one + # giant line (which can overflow awk line-length limits on some platforms) and instead parse + # line-by-line within the assets array. We intentionally return the *last* match to support + # rolling tags that may contain multiple versions: newest assets are appended later in the JSON. + printf '%s' "$json" | awk -v re="$name_regex" ' + BEGIN { + in_assets = 0 + name = "" + last = "" + } + { + raw = $0 + if (in_assets == 0) { + if (raw ~ /"assets"[[:space:]]*:[[:space:]]*\[/) { + in_assets = 1 + } + next + } + + # End of the assets array. The GitHub API pretty-prints `],` on its own line. + if (raw ~ /^[[:space:]]*][[:space:]]*,?[[:space:]]*$/) { + in_assets = 0 + next + } + + if (raw ~ /"name"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"name"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + if (q > 0) { + name = substr(v, 1, q - 1) + } + } + + if (raw ~ /"browser_download_url"[[:space:]]*:[[:space:]]*"/) { + v = raw + sub(/^.*"browser_download_url"[[:space:]]*:[[:space:]]*"/, "", v) + q = index(v, "\"") + url = "" + if (q > 0) { + url = substr(v, 1, q - 1) + } + if (name ~ re && url != "") { + last = url + } + } + } + END { + if (last != "") { + print last + } + } + ' +} + +action_version() { + if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then + echo "Invalid HAPPIER_CHANNEL='${CHANNEL}'. Expected stable or preview." >&2 + return 1 + fi + + local tag="stack-stable" + if [[ "${CHANNEL}" == "preview" ]]; then + tag="stack-preview" + fi + + local uname_os="" + uname_os="$(uname -s)" + local os="" + case "${uname_os}" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) + echo "Unsupported platform: ${uname_os}" >&2 + return 1 + ;; + esac + + local arch_raw="" + arch_raw="$(uname -m)" + local arch="" + case "${arch_raw}" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) + echo "Unsupported architecture: ${arch_raw}" >&2 + return 1 + ;; + esac + + local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + info "Fetching ${tag} release metadata..." + local release_json="" + if ! release_json="$(curl -fsSL "${api_url}")"; then + echo "Failed to fetch release metadata for Happier Stack." >&2 + return 1 + fi + + local asset_regex="^hstack-v.*-${os}-${arch}[.]tar[.]gz$" + local asset_url="" + asset_url="$(json_lookup_asset_url "${release_json}" "${asset_regex}")" + if [[ -z "${asset_url}" ]]; then + echo "Unable to locate release assets for ${os}-${arch} on tag ${tag}." >&2 + return 1 + fi + local asset_name="" + asset_name="$(basename "${asset_url}")" + local version="" + version="${asset_name#hstack-v}" + version="${version%-${os}-${arch}.tar.gz}" + if [[ -z "${version}" || "${version}" == "${asset_name}" ]]; then + echo "Failed to infer release version from asset name: ${asset_name}" >&2 + return 1 + fi + + say "Happier Stack installer version check" + say "- channel: ${CHANNEL}" + say "- mode: ${MODE}" + say "- platform: ${os}-${arch}" + say "- version: ${version}" + return 0 +} + +tar_extract_gz() { + local archive_path="$1" + local dest_dir="$2" + mkdir -p "${dest_dir}" + # GNU tar on Linux emits noisy, non-actionable warnings when extracting archives created by bsdtar/libarchive: + # "Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'" + # Filter those while preserving real errors. + tar -xzf "${archive_path}" -C "${dest_dir}" 2> >(grep -v -E "^tar: Ignoring unknown extended header keyword" >&2 || true) +} + usage() { cat <<'EOF' Usage: @@ -46,6 +230,14 @@ Options: --channel <stable|preview> --stable --preview + --check + --version + --reinstall + --restart + --uninstall [--purge-data] + --reset + --purge-data + --debug -h, --help EOF } @@ -117,6 +309,39 @@ while [[ $# -gt 0 ]]; do CHANNEL="preview" shift 1 ;; + --check) + ACTION="check" + shift 1 + ;; + --version) + ACTION="version" + shift 1 + ;; + --reinstall) + ACTION="install" + shift 1 + ;; + --restart) + ACTION="restart" + shift 1 + ;; + --uninstall) + ACTION="uninstall" + shift 1 + ;; + --reset) + ACTION="uninstall" + PURGE_DATA="1" + shift 1 + ;; + --purge-data) + PURGE_DATA="1" + shift 1 + ;; + --debug) + DEBUG_MODE="1" + shift 1 + ;; --) shift 1 break @@ -129,6 +354,10 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "${DEBUG_MODE}" == "1" ]]; then + set -x +fi + if [[ "${MODE}" != "user" && "${MODE}" != "system" ]]; then echo "Invalid mode: ${MODE}. Expected user or system." >&2 exit 1 @@ -139,6 +368,11 @@ if [[ "${CHANNEL}" != "stable" && "${CHANNEL}" != "preview" ]]; then exit 1 fi +if [[ "${ACTION}" == "version" ]]; then + action_version + exit $? +fi + UNAME="$(uname -s)" OS="" case "${UNAME}" in @@ -182,11 +416,6 @@ if [[ "${MODE}" == "system" && "${EUID}" -ne 0 ]]; then exit 1 fi -if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then - echo "systemctl is required for self-host installation on Linux." >&2 - exit 1 -fi - ARCH="$(uname -m)" case "${ARCH}" in x86_64|amd64) ARCH="x64" ;; @@ -202,52 +431,106 @@ if [[ "${CHANNEL}" == "preview" ]]; then TAG="stack-preview" fi -json_lookup_asset_url() { - local json="$1" - local name_regex="$2" - # GitHub API JSON is typically pretty-printed (newlines + spaces). Minify and then parse using a - # tiny jq-free state machine that pairs `"name":"..."` with the next `"browser_download_url":"..."`. - # We intentionally return the *last* match to support rolling tags that may contain multiple - # versions: newest assets are appended later in the release JSON. - printf '%s' "$json" | tr -d '[:space:]' | awk -v re="$name_regex" ' - { - s = $0 - assets_key = "\"assets\":[" - a = index(s, assets_key) - if (a > 0) { - s = substr(s, a + length(assets_key)) - } - name_key = "\"name\":\"" - url_key = "\"browser_download_url\":\"" - last = "" - while (1) { - p = index(s, name_key) - if (p == 0) break - s = substr(s, p + length(name_key)) - q = index(s, "\"") - if (q == 0) break - name = substr(s, 1, q - 1) - s = substr(s, q + 1) - - u = index(s, url_key) - if (u == 0) continue - s = substr(s, u + length(url_key)) - v = index(s, "\"") - if (v == 0) break - url = substr(s, 1, v - 1) - s = substr(s, v + 1) +resolve_hstack_path() { + if command -v hstack >/dev/null 2>&1; then + command -v hstack + return 0 + fi + if [[ -x "${STACK_INSTALL_DIR}/bin/hstack" ]]; then + echo "${STACK_INSTALL_DIR}/bin/hstack" + return 0 + fi + if [[ -x "${STACK_BIN_DIR}/hstack" ]]; then + echo "${STACK_BIN_DIR}/hstack" + return 0 + fi + return 1 +} - if (name ~ re && url != "") { - last = url - } - } - if (last != "") { - print last - } - } - ' +print_self_host_log_guidance() { + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi } +action_check() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + warn "Run: curl -fsSL https://happier.dev/self-host-preview | bash" + return 1 + fi + info "Checking Happier Self-Host..." + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + "${hstack}" self-host doctor --mode="${MODE}" --channel="${CHANNEL}" + return $? +} + +action_uninstall() { + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -z "${hstack}" ]]; then + warn "hstack is not installed." + return 1 + fi + local args=(self-host uninstall --yes --non-interactive --channel="${CHANNEL}" --mode="${MODE}") + if [[ "${PURGE_DATA}" == "1" ]]; then + args+=(--purge-data) + fi + "${hstack}" "${args[@]}" + return $? +} + +action_restart() { + local service="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + if [[ "${OS}" == "linux" ]] && command -v systemctl >/dev/null 2>&1; then + info "Restarting ${service}..." + if [[ "${MODE}" == "system" ]]; then + systemctl restart "${service}.service" + else + systemctl --user restart "${service}.service" + fi + fi + local hstack="" + hstack="$(resolve_hstack_path 2>/dev/null || true)" + if [[ -n "${hstack}" ]]; then + "${hstack}" self-host status --mode="${MODE}" --channel="${CHANNEL}" || true + fi + return 0 +} + +if [[ "${ACTION}" == "check" ]]; then + action_check + exit $? +fi +if [[ "${ACTION}" == "uninstall" ]]; then + action_uninstall + exit $? +fi +if [[ "${ACTION}" == "restart" ]]; then + action_restart + exit $? +fi + +if [[ "${OS}" == "linux" ]] && ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl is required for self-host installation on Linux." >&2 + exit 1 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -296,7 +579,7 @@ ensure_minisign() { local extract_dir="${TMP_DIR}/minisign-extract" mkdir -p "${extract_dir}" if [[ "${asset}" == *.tar.gz ]]; then - tar -xzf "${archive_path}" -C "${extract_dir}" + tar_extract_gz "${archive_path}" "${extract_dir}" else if command -v unzip >/dev/null 2>&1; then unzip -q "${archive_path}" -d "${extract_dir}" @@ -345,7 +628,7 @@ write_minisign_public_key() { } API_URL="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" -echo "Fetching ${TAG} release metadata..." +info "Fetching ${TAG} release metadata..." if ! RELEASE_JSON="$(curl -fsSL "${API_URL}")"; then if [[ "${CHANNEL}" == "stable" ]]; then echo "No stable releases found for Happier Stack." >&2 @@ -384,6 +667,9 @@ fi TMP_DIR="$(mktemp -d)" cleanup() { + if [[ "${DEBUG_MODE}" == "1" ]]; then + return + fi rm -rf "${TMP_DIR}" } trap cleanup EXIT @@ -393,7 +679,7 @@ CHECKSUMS_PATH="${TMP_DIR}/checksums.txt" curl -fsSL "${ASSET_URL}" -o "${ARCHIVE_PATH}" curl -fsSL "${CHECKSUMS_URL}" -o "${CHECKSUMS_PATH}" -EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1)" +EXPECTED_SHA="$(grep -E " $(basename "${ASSET_URL}")$" "${CHECKSUMS_PATH}" | awk '{print $1}' | head -n 1 || true)" if [[ -z "${EXPECTED_SHA}" ]]; then echo "Failed to resolve checksum for $(basename "${ASSET_URL}")" >&2 exit 1 @@ -403,7 +689,7 @@ if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then echo "Checksum verification failed." >&2 exit 1 fi -echo "Checksum verified." +success "Checksum verified." if ! ensure_minisign; then echo "minisign is required for installer signature verification." >&2 @@ -416,11 +702,11 @@ SIG_PATH="${TMP_DIR}/checksums.txt.minisig" write_minisign_public_key "${PUBKEY_PATH}" curl -fsSL "${SIG_URL}" -o "${SIG_PATH}" "${MINISIGN_BIN}" -Vm "${CHECKSUMS_PATH}" -x "${SIG_PATH}" -p "${PUBKEY_PATH}" >/dev/null -echo "Signature verified." +success "Signature verified." EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" -tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" +tar_extract_gz "${ARCHIVE_PATH}" "${EXTRACT_DIR}" BINARY_PATH="$(find "${EXTRACT_DIR}" -type f -name hstack -perm -u+x | head -n 1 || true)" if [[ -z "${BINARY_PATH}" ]]; then echo "Failed to locate extracted hstack binary." >&2 @@ -432,7 +718,7 @@ cp "${BINARY_PATH}" "${STACK_INSTALL_DIR}/bin/hstack" chmod +x "${STACK_INSTALL_DIR}/bin/hstack" ln -sf "${STACK_INSTALL_DIR}/bin/hstack" "${STACK_BIN_DIR}/hstack" -echo "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" +success "Installed hstack to ${STACK_INSTALL_DIR}/bin/hstack" SELF_HOST_ARGS=(self-host install --non-interactive --channel="${CHANNEL}" --mode="${MODE}") if [[ "${WITH_CLI}" != "1" ]]; then @@ -442,10 +728,56 @@ fi export HAPPIER_NONINTERACTIVE="${NONINTERACTIVE}" if [[ "${NONINTERACTIVE}" != "1" ]]; then - echo "Starting Happier Self-Host guided installation..." + info "Starting Happier Self-Host guided installation..." + say + info "This can take a few minutes. If it looks stuck, check logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " - ${SELF_HOST_LOG_DIR}/server.err.log" + say " - ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " - sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " - journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + say +fi +if ! "${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}"; then + warn + warn "[self-host] install failed" + say + info "Troubleshooting:" + say " ${STACK_BIN_DIR}/hstack self-host status --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host doctor --mode=${MODE} --channel=${CHANNEL}" + say " ${STACK_BIN_DIR}/hstack self-host config view --mode=${MODE} --channel=${CHANNEL} --json" + say + info "Logs:" + if [[ "${MODE}" == "system" ]]; then + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-/var/log/happier}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + else + SELF_HOST_LOG_DIR="${HAPPIER_SELF_HOST_LOG_DIR:-${HAPPIER_HOME}/self-host/logs}" + SELF_HOST_SERVICE_NAME="${HAPPIER_SELF_HOST_SERVICE_NAME:-happier-server}" + fi + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.err.log" + say " tail -n 200 ${SELF_HOST_LOG_DIR}/server.out.log" + if [[ "${OS}" == "linux" ]]; then + if [[ "${MODE}" == "system" ]]; then + say " sudo journalctl -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + else + say " journalctl --user -u ${SELF_HOST_SERVICE_NAME} -e --no-pager" + fi + fi + exit 1 fi -"${STACK_INSTALL_DIR}/bin/hstack" "${SELF_HOST_ARGS[@]}" echo -echo "Happier Self-Host installation completed." -echo "Run: ${STACK_BIN_DIR}/hstack self-host status" +success "Happier Self-Host installation completed." +info "Run: ${STACK_BIN_DIR}/hstack self-host status" diff --git a/scripts/release/installers_cli_actions.test.mjs b/scripts/release/installers_cli_actions.test.mjs new file mode 100644 index 000000000..d60b939ce --- /dev/null +++ b/scripts/release/installers_cli_actions.test.mjs @@ -0,0 +1,366 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('install.sh --check is read-only and reports missing install', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-cli-check-missing-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + const installDir = join(root, 'install'); + const outBinDir = join(root, 'out-bin'); + + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await mkdir(installDir, { recursive: true }); + await mkdir(outBinDir, { recursive: true }); + + // Fail the test if --check tries to fetch anything. + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho "curl should not run in --check" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'install.sh'); + const env = { + ...process.env, + HOME: homeDir, + SHELL: '/bin/bash', + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_PRODUCT: 'cli', + HAPPIER_INSTALL_DIR: installDir, + HAPPIER_BIN_DIR: outBinDir, + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--check'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 1, `expected check to fail when not installed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + assert.match(stdout + stderr, /not installed|missing/i); + + await rm(root, { recursive: true, force: true }); +}); + +test('install.sh --check reports installed binary and shim', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-cli-check-ok-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + const installDir = join(root, 'install'); + const outBinDir = join(root, 'out-bin'); + + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await mkdir(join(installDir, 'bin'), { recursive: true }); + await mkdir(outBinDir, { recursive: true }); + + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho "curl should not run in --check" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const happierPath = join(installDir, 'bin', 'happier'); + await writeFile( + happierPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "--version" ]]; then + echo "9.9.9" + exit 0 +fi +exit 0 +`, + 'utf8', + ); + await chmod(happierPath, 0o755); + + const shimPath = join(outBinDir, 'happier'); + await symlink(happierPath, shimPath); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'install.sh'); + const env = { + ...process.env, + HOME: homeDir, + SHELL: '/bin/bash', + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_PRODUCT: 'cli', + HAPPIER_INSTALL_DIR: installDir, + HAPPIER_BIN_DIR: outBinDir, + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--check'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 0, `check failed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + assert.match(stdout, /happier/i); + assert.match(stdout, /9\.9\.9/); + + await rm(root, { recursive: true, force: true }); +}); + +test('install.sh --uninstall removes installed binary and shim without network', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-cli-uninstall-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + const installDir = join(root, 'install'); + const outBinDir = join(root, 'out-bin'); + + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await mkdir(join(installDir, 'bin'), { recursive: true }); + await mkdir(outBinDir, { recursive: true }); + + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho "curl should not run in --uninstall" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const happierPath = join(installDir, 'bin', 'happier'); + await writeFile(happierPath, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(happierPath, 0o755); + const shimPath = join(outBinDir, 'happier'); + await symlink(happierPath, shimPath); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'install.sh'); + const env = { + ...process.env, + HOME: homeDir, + SHELL: '/bin/bash', + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_PRODUCT: 'cli', + HAPPIER_INSTALL_DIR: installDir, + HAPPIER_BIN_DIR: outBinDir, + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--uninstall'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 0, `uninstall failed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + + const checkBin = spawnSync('bash', ['-lc', `test ! -e "${happierPath.replaceAll('"', '\\"')}"`], { encoding: 'utf8' }); + assert.equal(checkBin.status, 0, 'expected binary to be removed'); + const checkShim = spawnSync('bash', ['-lc', `test ! -e "${shimPath.replaceAll('"', '\\"')}"`], { encoding: 'utf8' }); + assert.equal(checkShim.status, 0, 'expected shim to be removed'); + + await rm(root, { recursive: true, force: true }); +}); + +test('install.sh --reset purges the install directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-cli-reset-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + const installDir = join(root, 'install'); + const outBinDir = join(root, 'out-bin'); + + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await mkdir(join(installDir, 'bin'), { recursive: true }); + await mkdir(outBinDir, { recursive: true }); + + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho "curl should not run in --reset" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const happierPath = join(installDir, 'bin', 'happier'); + await writeFile(happierPath, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(happierPath, 0o755); + const shimPath = join(outBinDir, 'happier'); + await symlink(happierPath, shimPath); + + // Extra marker file to ensure purge removes the whole install directory. + await writeFile(join(installDir, 'marker.txt'), 'x', 'utf8'); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'install.sh'); + const env = { + ...process.env, + HOME: homeDir, + SHELL: '/bin/bash', + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_PRODUCT: 'cli', + HAPPIER_INSTALL_DIR: installDir, + HAPPIER_BIN_DIR: outBinDir, + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--reset'], { env, encoding: 'utf8' }); + assert.equal(res.status, 0, `reset failed:\n${String(res.stdout ?? '')}\n${String(res.stderr ?? '')}`); + + const checkInstallDir = spawnSync('bash', ['-lc', `test ! -d "${installDir.replaceAll('"', '\\"')}"`], { encoding: 'utf8' }); + assert.equal(checkInstallDir.status, 0, 'expected install dir to be removed'); + + await rm(root, { recursive: true, force: true }); +}); + +test('install.sh --restart restarts the CLI daemon without network', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-cli-restart-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + const installDir = join(root, 'install'); + const outBinDir = join(root, 'out-bin'); + + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await mkdir(join(installDir, 'bin'), { recursive: true }); + await mkdir(outBinDir, { recursive: true }); + + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho "curl should not run in --restart" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const tracePath = join(root, 'trace.txt'); + const happierPath = join(installDir, 'bin', 'happier'); + await writeFile( + happierPath, + `#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> ${JSON.stringify(tracePath)} +exit 0 +`, + 'utf8', + ); + await chmod(happierPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'install.sh'); + const env = { + ...process.env, + HOME: homeDir, + SHELL: '/bin/bash', + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_PRODUCT: 'cli', + HAPPIER_INSTALL_DIR: installDir, + HAPPIER_BIN_DIR: outBinDir, + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--restart'], { env, encoding: 'utf8' }); + assert.equal(res.status, 0, `restart failed:\n${String(res.stdout ?? '')}\n${String(res.stderr ?? '')}`); + + const trace = await readFile(tracePath, 'utf8').catch(() => ''); + assert.match(trace, /daemon service restart/i); + + await rm(root, { recursive: true, force: true }); +}); + +test('install.sh --reinstall is accepted and runs the install flow', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-cli-reinstall-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + const installDir = join(root, 'install'); + const outBinDir = join(root, 'out-bin'); + + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await mkdir(installDir, { recursive: true }); + await mkdir(outBinDir, { recursive: true }); + + const curlStubPath = join(binDir, 'curl'); + await writeFile( + curlStubPath, + '#!/usr/bin/env bash\n\necho "curl invoked" >&2\nexit 88\n', + 'utf8', + ); + await chmod(curlStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'install.sh'); + const env = { + ...process.env, + HOME: homeDir, + SHELL: '/bin/bash', + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_PRODUCT: 'cli', + HAPPIER_INSTALL_DIR: installDir, + HAPPIER_BIN_DIR: outBinDir, + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--reinstall'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 1, `expected reinstall to enter install flow and attempt fetching releases:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + assert.doesNotMatch(stdout + stderr, /unknown argument/i); + assert.match(stdout + stderr, /fetching .* release metadata/i); + assert.match(stdout + stderr, /curl invoked/i); + + await rm(root, { recursive: true, force: true }); +}); + +test('install.sh --version prints release version without installing', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-cli-version-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + const installDir = join(root, 'install'); + const outBinDir = join(root, 'out-bin'); + + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await mkdir(installDir, { recursive: true }); + await mkdir(outBinDir, { recursive: true }); + + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Linux + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo x86_64 + exit 0 +fi +echo Linux +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + const curlStubPath = join(binDir, 'curl'); + await writeFile( + curlStubPath, + `#!/usr/bin/env bash +set -euo pipefail +args="$*" +if [[ "$args" == *" -o "* ]]; then + echo "curl should not download assets in --version" >&2 + exit 99 +fi +cat <<'JSON' +{ + "assets": [ + { "name": "happier-v9.9.9-linux-x64.tar.gz", "browser_download_url": "https://example.invalid/happier-v9.9.9-linux-x64.tar.gz" } + ] +} +JSON +exit 0 +`, + 'utf8', + ); + await chmod(curlStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'install.sh'); + const env = { + ...process.env, + HOME: homeDir, + SHELL: '/bin/bash', + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_PRODUCT: 'cli', + HAPPIER_INSTALL_DIR: installDir, + HAPPIER_BIN_DIR: outBinDir, + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--version'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 0, `version failed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + assert.match(stdout + stderr, /\b9\.9\.9\b/); + assert.doesNotMatch(stdout + stderr, /Added .* to PATH/i); + + await rm(root, { recursive: true, force: true }); +}); diff --git a/scripts/release/installers_cli_etxtbsy_atomic_swap.test.mjs b/scripts/release/installers_cli_etxtbsy_atomic_swap.test.mjs new file mode 100644 index 000000000..3f93c559d --- /dev/null +++ b/scripts/release/installers_cli_etxtbsy_atomic_swap.test.mjs @@ -0,0 +1,180 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +async function sha256(path) { + const bytes = await readFile(path); + return createHash('sha256').update(bytes).digest('hex'); +} + +test('install.sh installs via atomic swap when cp to target would hit ETXTBSY', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-etxtbsy-')); + const binDir = join(root, 'bin'); + const installDir = join(root, 'install'); + const outBinDir = join(root, 'out-bin'); + const fixtureDir = join(root, 'fixture'); + await mkdir(binDir, { recursive: true }); + await mkdir(installDir, { recursive: true }); + await mkdir(outBinDir, { recursive: true }); + await mkdir(fixtureDir, { recursive: true }); + + // Stub uname so the installer deterministically selects linux-arm64 assets. + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Linux + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo aarch64 + exit 0 +fi +echo Linux +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + // Provide minisign so the installer does not bootstrap it. + const minisignStubPath = join(binDir, 'minisign'); + await writeFile(minisignStubPath, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(minisignStubPath, 0o755); + + // Build a minimal CLI tarball. + const version = '9.9.9'; + const artifactStem = `happier-v${version}-linux-arm64`; + const artifactName = `${artifactStem}.tar.gz`; + const artifactDir = join(fixtureDir, artifactStem); + await mkdir(artifactDir, { recursive: true }); + const happierBin = join(artifactDir, 'happier'); + await writeFile(happierBin, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(happierBin, 0o755); + + const tarPath = join(fixtureDir, artifactName); + const tarRes = spawnSync('tar', ['-czf', tarPath, '-C', fixtureDir, artifactStem], { encoding: 'utf8' }); + assert.equal(tarRes.status, 0, `tar failed: ${String(tarRes.stderr ?? '')}`); + + const checksumsName = `checksums-happier-v${version}.txt`; + const checksumsPath = join(fixtureDir, checksumsName); + const hash = await sha256(tarPath); + await writeFile(checksumsPath, `${hash} ${artifactName}\n`, 'utf8'); + + const sigName = `${checksumsName}.minisig`; + const sigPath = join(fixtureDir, sigName); + await writeFile(sigPath, 'minisign-stub\n', 'utf8'); + + // Stub curl: return release JSON (no -o), or copy fixture files to -o destinations. + const curlStubPath = join(binDir, 'curl'); + const releaseJson = `{ + "assets": [ + { + "name": "${artifactName}", + "browser_download_url": "https://example.test/${artifactName}" + }, + { + "name": "${checksumsName}", + "browser_download_url": "https://example.test/${checksumsName}" + }, + { + "name": "${sigName}", + "browser_download_url": "https://example.test/${sigName}" + } + ] +}`; + await writeFile( + curlStubPath, + `#!/usr/bin/env bash +set -euo pipefail +out="" +for ((i=1; i<=$#; i++)); do + if [[ "\${!i}" = "-o" ]]; then + j=$((i+1)) + out="\${!j}" + fi +done +url="" +for ((i=$#; i>=1; i--)); do + candidate="\${!i}" + if [[ -n "$out" && "$candidate" = "$out" ]]; then + continue + fi + if [[ "$candidate" = "-o" ]]; then + continue + fi + if [[ "$candidate" = -* ]]; then + continue + fi + url="$candidate" + break +done +if [[ -n "$out" ]]; then + case "$url" in + *${artifactName}) cp ${JSON.stringify(tarPath)} "$out" ;; + *${checksumsName}) cp ${JSON.stringify(checksumsPath)} "$out" ;; + *${sigName}) cp ${JSON.stringify(sigPath)} "$out" ;; + *) : > "$out" ;; + esac + exit 0 +fi +printf '%s' '${releaseJson}' +`, + 'utf8', + ); + await chmod(curlStubPath, 0o755); + + const realCp = String(spawnSync('bash', ['-lc', 'command -v cp'], { encoding: 'utf8' }).stdout ?? '').trim(); + assert.ok(realCp, 'expected cp to exist for installer test'); + + // Stub cp: fail with ETXTBSY-like message if the installer tries to copy directly onto the target binary path. + const cpStubPath = join(binDir, 'cp'); + const targetBinaryPath = join(installDir, 'bin', 'happier'); + await writeFile( + cpStubPath, + `#!/usr/bin/env bash +set -euo pipefail +src="$1" +dest="$2" +if [[ "$dest" = ${JSON.stringify(targetBinaryPath)} ]]; then + echo "cp: cannot create regular file '$dest': Text file busy" >&2 + exit 1 +fi +exec ${JSON.stringify(realCp)} "$@" +`, + 'utf8', + ); + await chmod(cpStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'install.sh'); + const env = { + ...process.env, + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_PRODUCT: 'cli', + HAPPIER_INSTALL_DIR: installDir, + HAPPIER_BIN_DIR: outBinDir, + HAPPIER_NO_PATH_UPDATE: '1', + HAPPIER_NONINTERACTIVE: '1', + HAPPIER_GITHUB_TOKEN: '', + GITHUB_TOKEN: '', + }; + + const res = spawnSync('bash', [installerPath, '--without-daemon'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 0, `installer failed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + assert.ok(stdout.includes('Checksum verified.'), 'installer should verify checksums'); + assert.ok(stdout.includes('Signature verified.'), 'installer should verify minisign signature'); + + await rm(root, { recursive: true, force: true }); +}); + diff --git a/scripts/release/installers_minisign_bootstrap_arch.test.mjs b/scripts/release/installers_minisign_bootstrap_arch.test.mjs index dc2c882fc..8839ae861 100644 --- a/scripts/release/installers_minisign_bootstrap_arch.test.mjs +++ b/scripts/release/installers_minisign_bootstrap_arch.test.mjs @@ -128,6 +128,32 @@ echo "$hash $file" ); await chmod(sha256sumStubPath, 0o755); + const realTar = String(spawnSync('bash', ['-lc', 'command -v tar'], { encoding: 'utf8' }).stdout ?? '').trim(); + assert.ok(realTar, 'expected tar to exist for installer test'); + + // Stub tar: emit the noisy LIBARCHIVE warnings on extract so the installer must suppress them. + const tarStubPath = join(binDir, 'tar'); + await writeFile( + tarStubPath, + `#!/usr/bin/env bash +set -euo pipefail +is_extract=0 +for arg in "$@"; do + if [[ "$arg" == -*x* ]] && [[ "$arg" != -*c* ]]; then + is_extract=1 + break + fi +done +if [[ "$is_extract" == "1" ]]; then + echo "tar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'" >&2 + echo "tar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'" >&2 +fi +exec ${JSON.stringify(realTar)} "$@" +`, + 'utf8', + ); + await chmod(tarStubPath, 0o755); + // Stub curl: return release JSON (no -o), or copy fixture files to -o destinations. const curlStubPath = join(binDir, 'curl'); const releaseJson = `{ @@ -195,6 +221,7 @@ printf '%s' '${releaseJson}' assert.ok(stdout.includes('Checksum verified.'), 'installer should verify checksums'); assert.ok(stdout.includes('Signature verified.'), 'installer should verify minisign signature'); + assert.doesNotMatch(stderr, /Ignoring unknown extended header keyword/i, 'installer should suppress non-actionable tar warnings'); await rm(root, { recursive: true, force: true }); }); diff --git a/scripts/release/installers_path_update_guidance.test.mjs b/scripts/release/installers_path_update_guidance.test.mjs new file mode 100644 index 000000000..a58076793 --- /dev/null +++ b/scripts/release/installers_path_update_guidance.test.mjs @@ -0,0 +1,189 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +async function sha256(path) { + const bytes = await readFile(path); + return createHash('sha256').update(bytes).digest('hex'); +} + +test('install.sh updates bash rc + login files and prints a reload hint', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-path-hint-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + const installDir = join(root, 'install'); + const outBinDir = join(root, 'out-bin'); + const fixtureDir = join(root, 'fixture'); + + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await mkdir(installDir, { recursive: true }); + await mkdir(outBinDir, { recursive: true }); + await mkdir(fixtureDir, { recursive: true }); + + // Create both interactive + login bash files to cover common PATH-loading entrypoints. + await writeFile(join(homeDir, '.bashrc'), '# bashrc\n', 'utf8'); + await writeFile(join(homeDir, '.profile'), '# profile\n', 'utf8'); + + // Stub uname so the installer deterministically selects linux-x64 assets. + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Linux + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo x86_64 + exit 0 +fi +echo Linux +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + // Build a minimal CLI tarball. + const version = '9.9.9'; + const artifactStem = `happier-v${version}-linux-x64`; + const artifactName = `${artifactStem}.tar.gz`; + const artifactDir = join(fixtureDir, artifactStem); + await mkdir(artifactDir, { recursive: true }); + const happierBin = join(artifactDir, 'happier'); + await writeFile( + happierBin, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "--version" ]]; then + echo "${version}" + exit 0 +fi +exit 0 +`, + 'utf8', + ); + await chmod(happierBin, 0o755); + + const tarPath = join(fixtureDir, artifactName); + const tarRes = spawnSync('tar', ['-czf', tarPath, '-C', fixtureDir, artifactStem], { encoding: 'utf8' }); + assert.equal(tarRes.status, 0, `tar failed: ${String(tarRes.stderr ?? '')}`); + + const checksumsName = `checksums-happier-v${version}.txt`; + const checksumsPath = join(fixtureDir, checksumsName); + const hash = await sha256(tarPath); + await writeFile(checksumsPath, `${hash} ${artifactName}\n`, 'utf8'); + + const sigName = `${checksumsName}.minisig`; + const sigPath = join(fixtureDir, sigName); + await writeFile(sigPath, 'minisign-stub\n', 'utf8'); + + // Stub minisign so signature verification succeeds. + const minisignStubPath = join(binDir, 'minisign'); + await writeFile( + minisignStubPath, + `#!/usr/bin/env bash +exit 0 +`, + 'utf8', + ); + await chmod(minisignStubPath, 0o755); + + // Stub sha256sum so the installer is deterministic across platforms. + const sha256sumStubPath = join(binDir, 'sha256sum'); + await writeFile( + sha256sumStubPath, + `#!/usr/bin/env bash +set -euo pipefail +file="$1" +hash="$(openssl dgst -sha256 "$file" | awk '{print $NF}')" +echo "$hash $file" +`, + 'utf8', + ); + await chmod(sha256sumStubPath, 0o755); + + // Stub curl: return release JSON (no -o), or copy fixture files to -o destinations. + const curlStubPath = join(binDir, 'curl'); + const releaseJson = `{ + "assets": [ + { + "name": "${artifactName}", + "browser_download_url": "https://example.test/${artifactName}" + }, + { + "name": "${checksumsName}", + "browser_download_url": "https://example.test/${checksumsName}" + }, + { + "name": "${sigName}", + "browser_download_url": "https://example.test/${sigName}" + } + ] +}`; + await writeFile( + curlStubPath, + `#!/usr/bin/env bash +set -euo pipefail +out="" +url="" +for ((i=1; i<=$#; i++)); do + if [[ "\${!i}" = "-o" ]]; then + j=$((i+1)) + out="\${!j}" + fi +done +url="\${@: -1}" +if [[ -n "$out" ]]; then + case "$url" in + *${artifactName}) cp ${JSON.stringify(tarPath)} "$out" ;; + *${checksumsName}) cp ${JSON.stringify(checksumsPath)} "$out" ;; + *${sigName}) cp ${JSON.stringify(sigPath)} "$out" ;; + *) : > "$out" ;; + esac + exit 0 +fi +printf '%s' '${releaseJson}' +`, + 'utf8', + ); + await chmod(curlStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'install.sh'); + const env = { + ...process.env, + HOME: homeDir, + SHELL: '/bin/bash', + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_PRODUCT: 'cli', + HAPPIER_INSTALL_DIR: installDir, + HAPPIER_BIN_DIR: outBinDir, + HAPPIER_NONINTERACTIVE: '1', + HAPPIER_GITHUB_TOKEN: '', + GITHUB_TOKEN: '', + }; + + const res = spawnSync('bash', [installerPath, '--without-daemon'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 0, `installer failed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + + const exportLine = `export PATH="${outBinDir}:$PATH"`; + const bashrc = await readFile(join(homeDir, '.bashrc'), 'utf8'); + const profile = await readFile(join(homeDir, '.profile'), 'utf8'); + assert.ok(bashrc.includes(exportLine), 'expected installer to add PATH export to ~/.bashrc'); + assert.ok(profile.includes(exportLine), 'expected installer to add PATH export to ~/.profile (login shells)'); + assert.match(stdout, /(source|reload).*(bashrc|profile)|open a new terminal/i, 'expected installer to print a PATH reload hint'); + + await rm(root, { recursive: true, force: true }); +}); + diff --git a/scripts/release/installers_self_host_actions.test.mjs b/scripts/release/installers_self_host_actions.test.mjs new file mode 100644 index 000000000..b35ba9579 --- /dev/null +++ b/scripts/release/installers_self_host_actions.test.mjs @@ -0,0 +1,417 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('self-host.sh --check uses existing hstack and does not fetch releases', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-self-host-check-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Linux + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo x86_64 + exit 0 +fi +echo Linux +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + const systemctlStubPath = join(binDir, 'systemctl'); + await writeFile(systemctlStubPath, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(systemctlStubPath, 0o755); + + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho \"curl should not run in --check\" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const tracePath = join(root, 'trace.txt'); + const hstackStubPath = join(binDir, 'hstack'); + await writeFile( + hstackStubPath, + `#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> ${JSON.stringify(tracePath)} +if [[ "$1" = "self-host" && "$2" = "doctor" ]]; then + exit 0 +fi +exit 0 +`, + 'utf8', + ); + await chmod(hstackStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'self-host.sh'); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_HOME: join(homeDir, '.happier'), + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--check', '--mode', 'user', '--channel', 'preview'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 0, `check failed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + + const trace = await readFile(tracePath, 'utf8').catch(() => ''); + assert.match(trace, /self-host status/i); + assert.match(trace, /self-host doctor/i); + + await rm(root, { recursive: true, force: true }); +}); + +test('self-host.sh --uninstall proxies to hstack self-host uninstall', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-self-host-uninstall-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Linux + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo x86_64 + exit 0 +fi +echo Linux +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + const systemctlStubPath = join(binDir, 'systemctl'); + await writeFile(systemctlStubPath, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(systemctlStubPath, 0o755); + + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho \"curl should not run in --uninstall\" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const tracePath = join(root, 'trace.txt'); + const hstackStubPath = join(binDir, 'hstack'); + await writeFile( + hstackStubPath, + `#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> ${JSON.stringify(tracePath)} +exit 0 +`, + 'utf8', + ); + await chmod(hstackStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'self-host.sh'); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_HOME: join(homeDir, '.happier'), + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--uninstall', '--purge-data', '--mode', 'user', '--channel', 'preview'], { + env, + encoding: 'utf8', + }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 0, `uninstall failed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + + const trace = await readFile(tracePath, 'utf8').catch(() => ''); + assert.match(trace, /self-host uninstall/i); + assert.match(trace, /--purge-data/i); + assert.match(trace, /--yes/i); + + await rm(root, { recursive: true, force: true }); +}); + +test('self-host.sh --reset proxies to hstack self-host uninstall --purge-data', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-self-host-reset-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Linux + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo x86_64 + exit 0 +fi +echo Linux +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + const systemctlStubPath = join(binDir, 'systemctl'); + await writeFile(systemctlStubPath, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(systemctlStubPath, 0o755); + + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho \"curl should not run in --reset\" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const tracePath = join(root, 'trace.txt'); + const hstackStubPath = join(binDir, 'hstack'); + await writeFile( + hstackStubPath, + `#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> ${JSON.stringify(tracePath)} +exit 0 +`, + 'utf8', + ); + await chmod(hstackStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'self-host.sh'); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_HOME: join(homeDir, '.happier'), + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--reset', '--mode', 'user', '--channel', 'preview'], { env, encoding: 'utf8' }); + assert.equal(res.status, 0, `reset failed:\n${String(res.stdout ?? '')}\n${String(res.stderr ?? '')}`); + + const trace = await readFile(tracePath, 'utf8').catch(() => ''); + assert.match(trace, /self-host uninstall/i); + assert.match(trace, /--purge-data/i); + assert.match(trace, /--yes/i); + + await rm(root, { recursive: true, force: true }); +}); + +test('self-host.sh --restart restarts the service and uses existing hstack without network', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-self-host-restart-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Linux + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo x86_64 + exit 0 +fi +echo Linux +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + const tracePath = join(root, 'trace.txt'); + + const systemctlStubPath = join(binDir, 'systemctl'); + await writeFile( + systemctlStubPath, + `#!/usr/bin/env bash +set -euo pipefail +echo "systemctl $*" >> ${JSON.stringify(tracePath)} +exit 0 +`, + 'utf8', + ); + await chmod(systemctlStubPath, 0o755); + + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho \"curl should not run in --restart\" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const hstackStubPath = join(binDir, 'hstack'); + await writeFile( + hstackStubPath, + `#!/usr/bin/env bash +set -euo pipefail +echo "hstack $*" >> ${JSON.stringify(tracePath)} +exit 0 +`, + 'utf8', + ); + await chmod(hstackStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'self-host.sh'); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_HOME: join(homeDir, '.happier'), + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--restart', '--mode', 'user', '--channel', 'preview'], { env, encoding: 'utf8' }); + assert.equal(res.status, 0, `restart failed:\n${String(res.stdout ?? '')}\n${String(res.stderr ?? '')}`); + + const trace = await readFile(tracePath, 'utf8').catch(() => ''); + assert.match(trace, /systemctl --user restart happier-server\.service/i); + assert.match(trace, /hstack self-host status/i); + + await rm(root, { recursive: true, force: true }); +}); + +test('self-host.sh --reinstall is accepted and runs the install flow', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-self-host-reinstall-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Linux + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo x86_64 + exit 0 +fi +echo Linux +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + const systemctlStubPath = join(binDir, 'systemctl'); + await writeFile(systemctlStubPath, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(systemctlStubPath, 0o755); + + const curlStubPath = join(binDir, 'curl'); + await writeFile(curlStubPath, '#!/usr/bin/env bash\necho \"curl invoked\" >&2\nexit 88\n', 'utf8'); + await chmod(curlStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'self-host.sh'); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_HOME: join(homeDir, '.happier'), + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--reinstall', '--mode', 'user', '--channel', 'preview'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 1, `expected reinstall to enter install flow and attempt fetching releases:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + assert.doesNotMatch(stdout + stderr, /unknown argument/i); + assert.match(stdout + stderr, /fetching .* release metadata/i); + assert.match(stdout + stderr, /curl invoked/i); + + await rm(root, { recursive: true, force: true }); +}); + +test('self-host.sh --version prints stack version without installing', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-installer-self-host-version-')); + const homeDir = join(root, 'home'); + const binDir = join(root, 'bin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); + + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Darwin + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo arm64 + exit 0 +fi +echo Darwin +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + const curlStubPath = join(binDir, 'curl'); + await writeFile( + curlStubPath, + `#!/usr/bin/env bash +set -euo pipefail +args="$*" +if [[ "$args" == *" -o "* ]]; then + echo "curl should not download assets in --version" >&2 + exit 99 +fi +cat <<'JSON' +{ + "assets": [ + { "name": "hstack-v1.2.3-darwin-arm64.tar.gz", "browser_download_url": "https://example.invalid/hstack-v1.2.3-darwin-arm64.tar.gz" } + ] +} +JSON +exit 0 +`, + 'utf8', + ); + await chmod(curlStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'self-host.sh'); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HAPPIER_HOME: join(homeDir, '.happier'), + HAPPIER_NONINTERACTIVE: '1', + }; + + const res = spawnSync('bash', [installerPath, '--version', '--mode', 'user', '--channel', 'preview'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 0, `version failed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + assert.match(stdout + stderr, /\b1\.2\.3\b/); + assert.doesNotMatch(stdout + stderr, /Starting Happier Self-Host guided installation/i); + + await rm(root, { recursive: true, force: true }); +}); diff --git a/scripts/release/installers_self_host_tar_noise_and_guidance.test.mjs b/scripts/release/installers_self_host_tar_noise_and_guidance.test.mjs new file mode 100644 index 000000000..3589ca558 --- /dev/null +++ b/scripts/release/installers_self_host_tar_noise_and_guidance.test.mjs @@ -0,0 +1,225 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +async function sha256(path) { + const bytes = await readFile(path); + return createHash('sha256').update(bytes).digest('hex'); +} + +test('self-host.sh suppresses non-actionable tar warnings and prints log guidance', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-self-host-installer-')); + const binDir = join(root, 'bin'); + const homeDir = join(root, 'home'); + const fixtureDir = join(root, 'fixture'); + await mkdir(binDir, { recursive: true }); + await mkdir(homeDir, { recursive: true }); + await mkdir(fixtureDir, { recursive: true }); + + // Stub uname so the installer deterministically selects linux-x64 assets. + const unameStubPath = join(binDir, 'uname'); + await writeFile( + unameStubPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" = "-s" ]]; then + echo Linux + exit 0 +fi +if [[ "$1" = "-m" ]]; then + echo x86_64 + exit 0 +fi +echo Linux +`, + 'utf8', + ); + await chmod(unameStubPath, 0o755); + + const systemctlStubPath = join(binDir, 'systemctl'); + await writeFile(systemctlStubPath, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(systemctlStubPath, 0o755); + + // Build a minimal hstack tarball. + const version = '1.2.3'; + const artifactStem = `hstack-v${version}-linux-x64`; + const artifactName = `${artifactStem}.tar.gz`; + const artifactDir = join(fixtureDir, artifactStem); + await mkdir(artifactDir, { recursive: true }); + const hstackBin = join(artifactDir, 'hstack'); + await writeFile( + hstackBin, + `#!/usr/bin/env bash +set -euo pipefail +exit 0 +`, + 'utf8', + ); + await chmod(hstackBin, 0o755); + + const tarPath = join(fixtureDir, artifactName); + const tarRes = spawnSync('tar', ['-czf', tarPath, '-C', fixtureDir, artifactStem], { encoding: 'utf8' }); + assert.equal(tarRes.status, 0, `tar failed: ${String(tarRes.stderr ?? '')}`); + + const checksumsName = `checksums-hstack-v${version}.txt`; + const checksumsPath = join(fixtureDir, checksumsName); + const hash = await sha256(tarPath); + await writeFile(checksumsPath, `${hash} ${artifactName}\n`, 'utf8'); + + const sigName = `${checksumsName}.minisig`; + const sigPath = join(fixtureDir, sigName); + await writeFile(sigPath, 'minisign-stub\n', 'utf8'); + + // Build a fake minisign archive; ensure_minisign should extract it and then run it. + const minisignRoot = join(fixtureDir, 'minisign-linux'); + const minisignX64 = join(minisignRoot, 'x86_64', 'minisign'); + await mkdir(join(minisignRoot, 'x86_64'), { recursive: true }); + await writeFile(minisignX64, '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + await chmod(minisignX64, 0o755); + + const minisignArchiveName = 'minisign-0.12-linux.tar.gz'; + const minisignArchivePath = join(fixtureDir, minisignArchiveName); + const minisignTarRes = spawnSync('tar', ['-czf', minisignArchivePath, '-C', fixtureDir, 'minisign-linux'], { encoding: 'utf8' }); + assert.equal(minisignTarRes.status, 0, `tar minisign failed: ${String(minisignTarRes.stderr ?? '')}`); + + // sha256sum stub: return the pinned minisign archive SHA, and compute real SHA256 otherwise. + const sha256sumStubPath = join(binDir, 'sha256sum'); + const pinnedMinisignSha = '9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73'; + await writeFile( + sha256sumStubPath, + `#!/usr/bin/env bash +set -euo pipefail +file="$1" +base="$(basename "$file")" +if [[ "$base" = "${minisignArchiveName}" ]]; then + echo "${pinnedMinisignSha} $file" + exit 0 +fi +hash="$(openssl dgst -sha256 "$file" | awk '{print $NF}')" +echo "$hash $file" +`, + 'utf8', + ); + await chmod(sha256sumStubPath, 0o755); + + const realTar = String(spawnSync('bash', ['-lc', 'command -v tar'], { encoding: 'utf8' }).stdout ?? '').trim(); + assert.ok(realTar, 'expected tar to exist for installer test'); + + // Stub tar: emit noisy LIBARCHIVE warnings on extract so the installer must suppress them. + const tarStubPath = join(binDir, 'tar'); + await writeFile( + tarStubPath, + `#!/usr/bin/env bash +set -euo pipefail +is_extract=0 +for arg in "$@"; do + if [[ "$arg" == -*x* ]] && [[ "$arg" != -*c* ]]; then + is_extract=1 + break + fi +done +if [[ "$is_extract" == "1" ]]; then + echo "tar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'" >&2 + echo "tar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'" >&2 +fi +exec ${JSON.stringify(realTar)} "$@" +`, + 'utf8', + ); + await chmod(tarStubPath, 0o755); + + // curl stub: return release JSON (no -o), or copy fixture files to -o destinations. + const curlStubPath = join(binDir, 'curl'); + const releaseJson = `{ + "assets": [ + { + "name": "${artifactName}", + "browser_download_url": "https://example.test/${artifactName}" + }, + { + "name": "${checksumsName}", + "browser_download_url": "https://example.test/${checksumsName}" + }, + { + "name": "${sigName}", + "browser_download_url": "https://example.test/${sigName}" + } + ] +}`; + await writeFile( + curlStubPath, + `#!/usr/bin/env bash +set -euo pipefail +out="" +for ((i=1; i<=$#; i++)); do + if [[ "\${!i}" = "-o" ]]; then + j=$((i+1)) + out="\${!j}" + fi +done +url="" +for ((i=$#; i>=1; i--)); do + candidate="\${!i}" + if [[ -n "$out" && "$candidate" = "$out" ]]; then + continue + fi + if [[ "$candidate" = "-o" ]]; then + continue + fi + if [[ "$candidate" = -* ]]; then + continue + fi + url="$candidate" + break +done +if [[ -n "$out" ]]; then + case "$url" in + *${artifactName}) cp ${JSON.stringify(tarPath)} "$out" ;; + *${checksumsName}) cp ${JSON.stringify(checksumsPath)} "$out" ;; + *${sigName}) cp ${JSON.stringify(sigPath)} "$out" ;; + *${minisignArchiveName}) cp ${JSON.stringify(minisignArchivePath)} "$out" ;; + *) : > "$out" ;; + esac + exit 0 +fi +printf '%s' '${releaseJson}' +`, + 'utf8', + ); + await chmod(curlStubPath, 0o755); + + const installerPath = join(repoRoot, 'scripts', 'release', 'installers', 'self-host.sh'); + const env = { + ...process.env, + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HOME: homeDir, + HAPPIER_HOME: join(homeDir, '.happier'), + HAPPIER_NONINTERACTIVE: '0', + HAPPIER_WITH_CLI: '0', + HAPPIER_GITHUB_TOKEN: '', + GITHUB_TOKEN: '', + }; + + const res = spawnSync('bash', [installerPath, '--mode', 'user', '--channel', 'preview'], { env, encoding: 'utf8' }); + const stdout = String(res.stdout ?? ''); + const stderr = String(res.stderr ?? ''); + assert.equal(res.status, 0, `installer failed:\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`); + + assert.ok(stdout.includes('Checksum verified.'), 'installer should verify checksums'); + assert.ok(stdout.includes('Signature verified.'), 'installer should verify minisign signature'); + assert.doesNotMatch(stderr, /Ignoring unknown extended header keyword/i, 'installer should suppress non-actionable tar warnings'); + + const expectedLogDir = join(env.HAPPIER_HOME, 'self-host', 'logs'); + assert.match(stdout, new RegExp(expectedLogDir.replaceAll('\\', '\\\\'))); + assert.match(stdout, /journalctl\s+--user\s+-u\s+happier-server/i); + + await rm(root, { recursive: true, force: true }); +}); diff --git a/scripts/release/npm_e2e_smoke.contract.test.mjs b/scripts/release/npm_e2e_smoke.contract.test.mjs index 998df9f35..ca6acf7db 100644 --- a/scripts/release/npm_e2e_smoke.contract.test.mjs +++ b/scripts/release/npm_e2e_smoke.contract.test.mjs @@ -8,7 +8,7 @@ import { existsSync } from 'node:fs'; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..', '..'); -const smokeDir = join(repoRoot, 'scripts', 'release', 'npm-e2e-smoke'); +const smokeDir = join(repoRoot, 'scripts', 'release', 'release-assets-e2e'); test('npm-e2e-smoke Dockerfile uses Node 22 policy', async () => { const dockerfilePath = join(smokeDir, 'Dockerfile'); diff --git a/scripts/release/npm_release_run_tests_auto_defaults.contract.test.mjs b/scripts/release/npm_release_run_tests_auto_defaults.contract.test.mjs new file mode 100644 index 000000000..1b2d42c72 --- /dev/null +++ b/scripts/release/npm_release_run_tests_auto_defaults.contract.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('npm release script defaults to skipping tests locally when run-tests=auto', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'npm', 'release-packages.mjs'), + '--channel', + 'preview', + '--publish-cli', + 'true', + '--publish-stack', + 'false', + '--publish-server', + 'false', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env, GITHUB_ACTIONS: '' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\byarn build\b/); + assert.doesNotMatch(out, /\byarn prepublishOnly\b/); +}); + +test('npm release script defaults to running tests in GitHub Actions when run-tests=auto', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'npm', 'release-packages.mjs'), + '--channel', + 'preview', + '--publish-cli', + 'true', + '--publish-stack', + 'false', + '--publish-server', + 'false', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env, GITHUB_ACTIONS: 'true' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\byarn prepublishOnly\b/); +}); + diff --git a/scripts/release/pipeline_docker_publish.contract.test.mjs b/scripts/release/pipeline_docker_publish.contract.test.mjs index 5d1e91900..87a2dfcb7 100644 --- a/scripts/release/pipeline_docker_publish.contract.test.mjs +++ b/scripts/release/pipeline_docker_publish.contract.test.mjs @@ -22,7 +22,7 @@ test('pipeline docker publish script supports dry-run and computes stable tags', 'true', '--build-relay', 'true', - '--build-devcontainer', + '--build-dev-box', 'true', '--dry-run', ], @@ -43,8 +43,53 @@ test('pipeline docker publish script supports dry-run and computes stable tags', assert.match(out, /--tag happierdev\/relay-server:stable-0123456789ab/); assert.match(out, /--tag happierdev\/relay-server:latest/); - assert.match(out, /--file docker\/devcontainer\/Dockerfile/); - assert.match(out, /--tag happierdev\/dev-container:stable/); - assert.match(out, /--tag happierdev\/dev-container:stable-0123456789ab/); - assert.match(out, /--tag happierdev\/dev-container:latest/); + assert.match(out, /--file docker\/dev-box\/Dockerfile/); + assert.match(out, /--tag happierdev\/dev-box:stable/); + assert.match(out, /--tag happierdev\/dev-box:stable-0123456789ab/); + assert.match(out, /--tag happierdev\/dev-box:latest/); +}); + +test('pipeline docker publish script can also tag/push to GHCR', async () => { + const sha = '0123456789abcdef0123456789abcdef01234567'; + + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'docker', 'publish-images.mjs'), + '--channel', + 'stable', + '--sha', + sha, + '--push-latest', + 'true', + '--build-relay', + 'true', + '--build-dev-box', + 'true', + '--dry-run', + ], + { + cwd: repoRoot, + env: { + ...process.env, + PIPELINE_DOCKER_REGISTRIES: 'dockerhub,ghcr', + GHCR_NAMESPACE: 'ghcr.io/happier-dev', + GHCR_USERNAME: 'test-user', + GHCR_TOKEN: 'test-token', + DOCKERHUB_USERNAME: 'dockerhub-user', + DOCKERHUB_TOKEN: 'dockerhub-token', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /--tag ghcr\.io\/happier-dev\/relay-server:stable/); + assert.match(out, /--tag ghcr\.io\/happier-dev\/relay-server:stable-0123456789ab/); + assert.match(out, /--tag ghcr\.io\/happier-dev\/relay-server:latest/); + + assert.match(out, /--tag ghcr\.io\/happier-dev\/dev-box:stable/); + assert.match(out, /--tag ghcr\.io\/happier-dev\/dev-box:stable-0123456789ab/); + assert.match(out, /--tag ghcr\.io\/happier-dev\/dev-box:latest/); }); diff --git a/scripts/release/pipeline_docker_publish_buildx_builder.contract.test.mjs b/scripts/release/pipeline_docker_publish_buildx_builder.contract.test.mjs index 0009ac11a..959f0ce17 100644 --- a/scripts/release/pipeline_docker_publish_buildx_builder.contract.test.mjs +++ b/scripts/release/pipeline_docker_publish_buildx_builder.contract.test.mjs @@ -72,7 +72,7 @@ function runPublishImagesWithFakeDocker({ inspectDriver, fallbackExists }) { 'false', '--build-relay', 'true', - '--build-devcontainer', + '--build-dev-box', 'false', ], { diff --git a/scripts/release/pipeline_docker_publish_cli.contract.test.mjs b/scripts/release/pipeline_docker_publish_cli.contract.test.mjs index 2b20fe638..012a7b5a2 100644 --- a/scripts/release/pipeline_docker_publish_cli.contract.test.mjs +++ b/scripts/release/pipeline_docker_publish_cli.contract.test.mjs @@ -37,3 +37,34 @@ test('pipeline CLI can docker-publish in dry-run using env-only secrets', async assert.match(out, /--tag happierdev\/relay-server:stable-0123456789ab/); }); +test('pipeline CLI docker-publish forwards --registries to include GHCR tags', async () => { + const sha = '0123456789abcdef0123456789abcdef01234567'; + + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'run.mjs'), + 'docker-publish', + '--channel', + 'stable', + '--sha', + sha, + '--registries', + 'dockerhub,ghcr', + '--dry-run', + '--secrets-source', + 'env', + ], + { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\[pipeline\] docker publish: channel=stable/); + assert.match(out, /--tag happierdev\/relay-server:stable\b/); + assert.match(out, /--tag ghcr\.io\/happier-dev\/relay-server:stable\b/); +}); diff --git a/scripts/release/pipeline_docker_publish_ghcr_uses_gh_cli.contract.test.mjs b/scripts/release/pipeline_docker_publish_ghcr_uses_gh_cli.contract.test.mjs new file mode 100644 index 000000000..09f2068ab --- /dev/null +++ b/scripts/release/pipeline_docker_publish_ghcr_uses_gh_cli.contract.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +function writeExecutable(filePath, contents) { + writeFileSync(filePath, contents, { encoding: 'utf8', mode: 0o755 }); + chmodSync(filePath, 0o755); +} + +test('pipeline docker publish can use gh CLI auth for GHCR when local env is missing GHCR_*', async () => { + const binDir = mkdtempSync(resolve(tmpdir(), 'happier-pipeline-ghcr-bin-')); + const dockerPath = resolve(binDir, 'docker'); + const ghPath = resolve(binDir, 'gh'); + + writeExecutable( + ghPath, + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = "auth" ] && [ "$2" = "token" ]; then + echo "gh-test-token" + exit 0 +fi +if [ "$1" = "api" ] && [ "$2" = "user" ]; then + echo "gh-test-user" + exit 0 +fi +echo "unsupported gh args: $*" >&2 +exit 2 +`, + ); + + writeExecutable( + dockerPath, + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = "info" ]; then + exit 0 +fi +if [ "$1" = "buildx" ] && [ "$2" = "inspect" ]; then + echo "Driver: docker-container" + exit 0 +fi +if [ "$1" = "login" ]; then + user="" + shift + while [ $# -gt 0 ]; do + if [ "$1" = "--username" ]; then + shift + user="$1" + break + fi + shift + done + echo "FAKE_DOCKER_LOGIN_USER=$user" + exit 0 +fi +echo "unsupported docker args: $*" >&2 +exit 2 +`, + ); + + const sha = '0123456789abcdef0123456789abcdef01234567'; + + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'docker', 'publish-images.mjs'), + '--channel', + 'preview', + '--sha', + sha, + '--push-latest', + 'false', + '--build-relay', + 'false', + '--build-dev-box', + 'false', + ], + { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + PIPELINE_DOCKER_REGISTRIES: 'ghcr', + GHCR_NAMESPACE: 'ghcr.io/happier-dev', + // Ensure we're in "local" mode, so fallback to gh CLI is allowed. + GITHUB_ACTIONS: 'false', + // Ensure the test fails unless fallback works. + GHCR_USERNAME: '', + GHCR_TOKEN: '', + GITHUB_ACTOR: '', + GITHUB_TOKEN: '', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\[pipeline\] docker login: ghcr\.io\b/); + assert.match(out, /FAKE_DOCKER_LOGIN_USER=gh-test-user\b/); +}); diff --git a/scripts/release/pipeline_docker_publish_recovers_from_docker_down_on_macos.contract.test.mjs b/scripts/release/pipeline_docker_publish_recovers_from_docker_down_on_macos.contract.test.mjs index 2b9fc08db..b7ce92975 100644 --- a/scripts/release/pipeline_docker_publish_recovers_from_docker_down_on_macos.contract.test.mjs +++ b/scripts/release/pipeline_docker_publish_recovers_from_docker_down_on_macos.contract.test.mjs @@ -98,7 +98,7 @@ test('docker publish attempts to start Docker Desktop on macOS when docker info 'false', '--build-relay', 'true', - '--build-devcontainer', + '--build-dev-box', 'false', ], { @@ -113,4 +113,3 @@ test('docker publish attempts to start Docker Desktop on macOS when docker info assert.match(out, /\bOPEN -a Docker\b/); assert.match(out, /^BUILD\b/m); }); - diff --git a/scripts/release/pipeline_docker_publish_retries_transient_failures.contract.test.mjs b/scripts/release/pipeline_docker_publish_retries_transient_failures.contract.test.mjs index 63e0a635c..1c11b6bf7 100644 --- a/scripts/release/pipeline_docker_publish_retries_transient_failures.contract.test.mjs +++ b/scripts/release/pipeline_docker_publish_retries_transient_failures.contract.test.mjs @@ -84,7 +84,7 @@ test('docker publish retries transient buildx failures (EOF) once', () => { 'false', '--build-relay', 'true', - '--build-devcontainer', + '--build-dev-box', 'false', ], { @@ -99,4 +99,3 @@ test('docker publish retries transient buildx failures (EOF) once', () => { const buildLines = out.match(/^BUILD\b.*$/gm) ?? []; assert.equal(buildLines.length, 2); }); - diff --git a/scripts/release/pipeline_expo_native_build_local.contract.test.mjs b/scripts/release/pipeline_expo_native_build_local.contract.test.mjs index 8ac3c83ad..0124bd02f 100644 --- a/scripts/release/pipeline_expo_native_build_local.contract.test.mjs +++ b/scripts/release/pipeline_expo_native_build_local.contract.test.mjs @@ -74,6 +74,7 @@ test('expo native-build supports local mode and writes build metadata json', () assert.match(stdout, /CWD=.*happier-pipeline-dagger-repo-.*\/apps\/ui\b/); assert.match(stdout, /NPX --yes eas-cli@/); assert.match(stdout, /\s--local\b/); + assert.match(stdout, /\s--non-interactive\b/); assert.ok(fs.existsSync(artifactOut), 'expected local build artifact to be created'); const parsed = JSON.parse(fs.readFileSync(outJson, 'utf8')); @@ -82,3 +83,125 @@ test('expo native-build supports local mode and writes build metadata json', () assert.equal(parsed.profile, 'preview-apk'); assert.equal(path.resolve(parsed.artifactPath), path.resolve(artifactOut)); }); + +test('expo native-build runs local builds non-interactively in CI', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'happier-pipeline-eas-local-ci-')); + const binDir = path.join(dir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + const outJson = path.join(dir, 'out.json'); + const artifactOut = path.join(dir, 'app.apk'); + + const npxPath = path.join(binDir, 'npx'); + writeExecutable( + npxPath, + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + 'echo "NPX $*"', + // Simulate `eas build --local --output <path>` by creating the output file. + 'out=""', + 'for ((i=1;i<=$#;i++)); do', + ' if [ "${!i}" = "--output" ]; then', + ' j=$((i+1))', + ' out="${!j}"', + ' fi', + 'done', + 'if [ -z "${out}" ]; then echo "missing --output" >&2; exit 1; fi', + 'mkdir -p "$(dirname "${out}")"', + 'head -c 1000001 /dev/zero > "${out}"', + 'exit 0', + '', + ].join('\n'), + ); + + const env = { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + EXPO_TOKEN: 'test-token', + CI: 'true', + }; + + const stdout = execFileSync( + process.execPath, + [ + path.join(repoRoot, 'scripts', 'pipeline', 'expo', 'native-build.mjs'), + '--platform', + 'android', + '--profile', + 'preview-apk', + '--out', + outJson, + '--build-mode', + 'local', + '--artifact-out', + artifactOut, + ], + { cwd: repoRoot, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }, + ); + + assert.match(stdout, /NPX --yes eas-cli@/); + assert.match(stdout, /\s--local\b/); + assert.match(stdout, /\s--non-interactive\b/); +}); + +test('expo native-build allows interactive local builds when PIPELINE_INTERACTIVE=1', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'happier-pipeline-eas-local-interactive-')); + const binDir = path.join(dir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + const outJson = path.join(dir, 'out.json'); + const artifactOut = path.join(dir, 'app.apk'); + + const npxPath = path.join(binDir, 'npx'); + writeExecutable( + npxPath, + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + 'echo "NPX $*"', + // Simulate `eas build --local --output <path>` by creating the output file. + 'out=""', + 'for ((i=1;i<=$#;i++)); do', + ' if [ "${!i}" = "--output" ]; then', + ' j=$((i+1))', + ' out="${!j}"', + ' fi', + 'done', + 'if [ -z "${out}" ]; then echo "missing --output" >&2; exit 1; fi', + 'mkdir -p "$(dirname "${out}")"', + 'head -c 1000001 /dev/zero > "${out}"', + 'exit 0', + '', + ].join('\n'), + ); + + const env = { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + EXPO_TOKEN: 'test-token', + PIPELINE_INTERACTIVE: '1', + }; + + const stdout = execFileSync( + process.execPath, + [ + path.join(repoRoot, 'scripts', 'pipeline', 'expo', 'native-build.mjs'), + '--platform', + 'android', + '--profile', + 'preview-apk', + '--out', + outJson, + '--build-mode', + 'local', + '--artifact-out', + artifactOut, + ], + { cwd: repoRoot, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }, + ); + + assert.match(stdout, /NPX --yes eas-cli@/); + assert.match(stdout, /\s--local\b/); + assert.doesNotMatch(stdout, /\s--non-interactive\b/); +}); diff --git a/scripts/release/pipeline_expo_submit_interactive_auth.contract.test.mjs b/scripts/release/pipeline_expo_submit_interactive_auth.contract.test.mjs new file mode 100644 index 000000000..855327320 --- /dev/null +++ b/scripts/release/pipeline_expo_submit_interactive_auth.contract.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const repoRoot = path.resolve(import.meta.dirname, '..', '..'); + +test('expo submit allows local interactive auth when EXPO_TOKEN is missing (dry-run)', () => { + const out = execFileSync( + process.execPath, + [path.join(repoRoot, 'scripts', 'pipeline', 'expo', 'submit.mjs'), '--environment', 'preview', '--platform', 'android', '--dry-run'], + { + cwd: repoRoot, + env: { + ...process.env, + CI: '', + EXPO_TOKEN: '', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\[pipeline\] expo submit: environment=preview platform=android/); + assert.match(out, /\[dry-run\].*\bnpx\b/); + assert.doesNotMatch(out, /\s--non-interactive\b/); +}); + +test('expo submit allows local interactive setup when PIPELINE_INTERACTIVE=1 (even with EXPO_TOKEN) (dry-run)', () => { + const out = execFileSync( + process.execPath, + [path.join(repoRoot, 'scripts', 'pipeline', 'expo', 'submit.mjs'), '--environment', 'preview', '--platform', 'android', '--dry-run'], + { + cwd: repoRoot, + env: { + ...process.env, + CI: '', + EXPO_TOKEN: 'test-token', + PIPELINE_INTERACTIVE: '1', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\[pipeline\] expo submit: environment=preview platform=android/); + assert.match(out, /\[dry-run\].*\bnpx\b/); + assert.doesNotMatch(out, /\s--non-interactive\b/); +}); + +test('expo submit requires EXPO_TOKEN in CI (even in dry-run)', () => { + let err; + try { + execFileSync( + process.execPath, + [path.join(repoRoot, 'scripts', 'pipeline', 'expo', 'submit.mjs'), '--environment', 'preview', '--platform', 'android', '--dry-run'], + { + cwd: repoRoot, + env: { + ...process.env, + CI: 'true', + EXPO_TOKEN: '', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + } catch (e) { + err = e; + } + + assert.ok(err, 'expected script to fail without EXPO_TOKEN in CI'); + assert.match(String(err?.stderr ?? ''), /EXPO_TOKEN is required/); +}); diff --git a/scripts/release/pipeline_expo_submit_ios_bundle_mismatch.contract.test.mjs b/scripts/release/pipeline_expo_submit_ios_bundle_mismatch.contract.test.mjs new file mode 100644 index 000000000..1dd204831 --- /dev/null +++ b/scripts/release/pipeline_expo_submit_ios_bundle_mismatch.contract.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const repoRoot = path.resolve(import.meta.dirname, '..', '..'); + +test('expo-submit fails when iOS archive bundle id does not match requested environment', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'happier-pipeline-expo-submit-bundle-')); + const artifact = path.join(tmp, 'app.ipa'); + + const pythonScript = [ + 'import sys, zipfile', + 'ipa_path = sys.argv[1]', + 'bundle_id = sys.argv[2]', + 'plist = f"""<?xml version="1.0" encoding="UTF-8"?>', + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">', + '<plist version="1.0"><dict>', + '<key>CFBundleIdentifier</key><string>{bundle_id}</string>', + '<key>CFBundleDisplayName</key><string>Happier (preview)</string>', + '<key>CFBundleShortVersionString</key><string>0.1.0</string>', + '<key>CFBundleVersion</key><string>48</string>', + '</dict></plist>"""', + 'with zipfile.ZipFile(ipa_path, "w") as z:', + ' z.writestr("Payload/Happierpreview.app/Info.plist", plist)', + ].join('\n'); + + execFileSync( + 'python3', + [ + '-c', + pythonScript, + artifact, + 'dev.happier.app.preview', + ], + { cwd: repoRoot, stdio: 'ignore', timeout: 30_000 }, + ); + + assert.throws( + () => + execFileSync( + process.execPath, + [ + path.join(repoRoot, 'scripts', 'pipeline', 'expo', 'submit.mjs'), + '--environment', + 'production', + '--platform', + 'ios', + '--profile', + 'production', + '--path', + artifact, + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env, EXPO_TOKEN: '' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ), + (err) => { + assert.equal(typeof err, 'object'); + const stderr = /** @type {any} */ (err).stderr?.toString?.() ?? ''; + assert.match(stderr, /bundle identifier/i); + assert.match(stderr, /dev\.happier\.app\.preview/); + assert.match(stderr, /production/); + return true; + }, + ); +}); diff --git a/scripts/release/pipeline_expo_submit_missing_path.contract.test.mjs b/scripts/release/pipeline_expo_submit_missing_path.contract.test.mjs new file mode 100644 index 000000000..0698dae63 --- /dev/null +++ b/scripts/release/pipeline_expo_submit_missing_path.contract.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const repoRoot = path.resolve(import.meta.dirname, '..', '..'); + +test('expo submit fails fast with a helpful message when --path does not exist', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'happier-pipeline-expo-submit-missing-')); + const missing = path.join(dir, 'missing.ipa'); + + /** @type {unknown} */ + let caught = null; + try { + execFileSync( + process.execPath, + [ + path.join(repoRoot, 'scripts', 'pipeline', 'expo', 'submit.mjs'), + '--environment', + 'production', + '--platform', + 'ios', + '--path', + missing, + ], + { + cwd: repoRoot, + env: { ...process.env, EXPO_TOKEN: 'test-token' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + } catch (err) { + caught = err; + } + + assert.ok(caught, 'expected execFileSync to throw'); + const stderr = String(/** @type {any} */ (caught).stderr ?? ''); + assert.match(stderr, /doesn't exist/); + assert.match(stderr, /happier-production-ios-v<uiVersion>\.ipa/); +}); + diff --git a/scripts/release/pipeline_expo_submit_path.contract.test.mjs b/scripts/release/pipeline_expo_submit_path.contract.test.mjs index 7c1894d27..ffa89f5ba 100644 --- a/scripts/release/pipeline_expo_submit_path.contract.test.mjs +++ b/scripts/release/pipeline_expo_submit_path.contract.test.mjs @@ -15,6 +15,8 @@ function runSubmit({ withPath }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'happier-pipeline-expo-submit-')); const binDir = path.join(dir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); + const artifactPath = path.join(dir, 'app.apk'); + if (withPath) fs.writeFileSync(artifactPath, 'placeholder'); const npxPath = path.join(binDir, 'npx'); writeExecutable( @@ -41,7 +43,7 @@ function runSubmit({ withPath }) { 'preview', '--platform', 'android', - ...(withPath ? ['--path', path.join(dir, 'app.apk')] : []), + ...(withPath ? ['--path', artifactPath] : []), ]; return execFileSync(process.execPath, args, { @@ -57,6 +59,7 @@ test('expo submit uses --latest by default (cloud builds)', () => { const out = runSubmit({ withPath: false }); assert.match(out, /NPX --yes eas-cli@/); assert.match(out, /\ssubmit\b/); + assert.match(out, /\s--profile preview\b/); assert.match(out, /\s--latest\b/); }); diff --git a/scripts/release/pipeline_expo_submit_path_cli.contract.test.mjs b/scripts/release/pipeline_expo_submit_path_cli.contract.test.mjs index 7f2d27553..e6ac16dbf 100644 --- a/scripts/release/pipeline_expo_submit_path_cli.contract.test.mjs +++ b/scripts/release/pipeline_expo_submit_path_cli.contract.test.mjs @@ -10,7 +10,33 @@ const repoRoot = path.resolve(import.meta.dirname, '..', '..'); test('pipeline CLI supports --path for expo-submit (dry-run)', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'happier-pipeline-expo-submit-cli-')); const artifact = path.join(tmp, 'app.ipa'); - fs.writeFileSync(artifact, 'placeholder'); + + const pythonScript = [ + 'import sys, zipfile', + 'ipa_path = sys.argv[1]', + 'bundle_id = sys.argv[2]', + 'plist = f"""<?xml version="1.0" encoding="UTF-8"?>', + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">', + '<plist version="1.0"><dict>', + '<key>CFBundleIdentifier</key><string>{bundle_id}</string>', + '<key>CFBundleDisplayName</key><string>Happier (preview)</string>', + '<key>CFBundleShortVersionString</key><string>0.1.0</string>', + '<key>CFBundleVersion</key><string>48</string>', + '</dict></plist>"""', + 'with zipfile.ZipFile(ipa_path, "w") as z:', + ' z.writestr("Payload/Happierpreview.app/Info.plist", plist)', + ].join('\n'); + + execFileSync( + 'python3', + [ + '-c', + pythonScript, + artifact, + 'dev.happier.app.preview', + ], + { cwd: repoRoot, stdio: 'ignore', timeout: 30_000 }, + ); const out = execFileSync( process.execPath, @@ -29,7 +55,7 @@ test('pipeline CLI supports --path for expo-submit (dry-run)', () => { ], { cwd: repoRoot, - env: { ...process.env, EXPO_TOKEN: 'expo-token' }, + env: { ...process.env, EXPO_TOKEN: '' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000, @@ -39,4 +65,3 @@ test('pipeline CLI supports --path for expo-submit (dry-run)', () => { assert.match(out, /scripts\/pipeline\/expo\/submit\.mjs/); assert.match(out, /\s--path\b/); }); - diff --git a/scripts/release/pipeline_git_clean_worktree.test.mjs b/scripts/release/pipeline_git_clean_worktree.test.mjs new file mode 100644 index 000000000..8d8a49f5e --- /dev/null +++ b/scripts/release/pipeline_git_clean_worktree.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import { assertCleanWorktree } from '../pipeline/git/ensure-clean-worktree.mjs'; + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +test('assertCleanWorktree passes on a clean repo', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'happier-git-clean-')); + git(dir, ['init']); + git(dir, ['config', 'user.email', 'test@example.com']); + git(dir, ['config', 'user.name', 'Test']); + + await writeFile(path.join(dir, 'a.txt'), 'hello\n', 'utf8'); + git(dir, ['add', 'a.txt']); + git(dir, ['commit', '-m', 'init']); + + assertCleanWorktree({ cwd: dir, allowDirty: false }); +}); + +test('assertCleanWorktree fails when repo has uncommitted changes (unless allowDirty)', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'happier-git-dirty-')); + git(dir, ['init']); + git(dir, ['config', 'user.email', 'test@example.com']); + git(dir, ['config', 'user.name', 'Test']); + + await writeFile(path.join(dir, 'a.txt'), 'hello\n', 'utf8'); + git(dir, ['add', 'a.txt']); + git(dir, ['commit', '-m', 'init']); + + await writeFile(path.join(dir, 'a.txt'), 'changed\n', 'utf8'); + + assert.throws(() => assertCleanWorktree({ cwd: dir, allowDirty: false }), /git worktree is dirty/i); + assert.doesNotThrow(() => assertCleanWorktree({ cwd: dir, allowDirty: true })); +}); + diff --git a/scripts/release/pipeline_github_audit_release_assets.contract.test.mjs b/scripts/release/pipeline_github_audit_release_assets.contract.test.mjs new file mode 100644 index 000000000..9568d151f --- /dev/null +++ b/scripts/release/pipeline_github_audit_release_assets.contract.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); +const scriptPath = resolve(repoRoot, 'scripts', 'pipeline', 'github', 'audit-release-assets.mjs'); + +function run(args) { + return spawnSync(process.execPath, [scriptPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + }); +} + +test('audit-release-assets fails when expected assets are missing', async () => { + const res = run([ + '--tag', + 'cli-preview', + '--kind', + 'cli', + '--version', + '1.2.3-preview.123.2', + '--targets', + 'linux-x64,linux-arm64', + '--assets-json', + JSON.stringify([ + 'happier-v1.2.3-preview.123.2-linux-x64.tar.gz', + 'checksums-happier-v1.2.3-preview.123.2.txt', + 'checksums-happier-v1.2.3-preview.123.2.txt.minisig', + ]), + ]); + + assert.notEqual(res.status, 0); + const output = String(res.stdout ?? '') + String(res.stderr ?? ''); + assert.match(output, /missing/i); + assert.match(output, /linux-arm64/); +}); + +test('audit-release-assets succeeds when all expected assets are present', async () => { + const res = run([ + '--tag', + 'cli-preview', + '--kind', + 'cli', + '--version', + '1.2.3-preview.123.2', + '--targets', + 'linux-x64,linux-arm64', + '--assets-json', + JSON.stringify([ + 'happier-v1.2.3-preview.123.2-linux-x64.tar.gz', + 'happier-v1.2.3-preview.123.2-linux-arm64.tar.gz', + 'checksums-happier-v1.2.3-preview.123.2.txt', + 'checksums-happier-v1.2.3-preview.123.2.txt.minisig', + ]), + ]); + + assert.equal(res.status, 0, `expected exit 0, got ${res.status} stderr=${res.stderr}`); +}); + diff --git a/scripts/release/pipeline_github_commit_and_push.contract.test.mjs b/scripts/release/pipeline_github_commit_and_push.contract.test.mjs new file mode 100644 index 000000000..b1e9380a7 --- /dev/null +++ b/scripts/release/pipeline_github_commit_and_push.contract.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('pipeline github commit+push script supports dry-run and optional missing paths', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'github', 'commit-and-push.mjs'), + '--paths', + 'apps/ui/package.json,apps/ui/DOES_NOT_EXIST', + '--allow-missing', + 'true', + '--message', + 'chore(release): bump', + '--push-ref', + 'dev', + '--push-mode', + 'auto', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\bgit add\b/); + assert.match(out, /\bgit commit\b/); + assert.match(out, /\bgit ls-remote\b/); + assert.match(out, /\bgit push\b/); + assert.match(out, /\bDID_COMMIT=true\b/); +}); diff --git a/scripts/release/pipeline_help.contract.test.mjs b/scripts/release/pipeline_help.contract.test.mjs new file mode 100644 index 000000000..3519cc6fb --- /dev/null +++ b/scripts/release/pipeline_help.contract.test.mjs @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createAnsiStyle } from '../pipeline/cli/ansi-style.mjs'; +import { renderCommandHelp } from '../pipeline/cli/help.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); +const pipelineCli = resolve(repoRoot, 'scripts', 'pipeline', 'run.mjs'); + +test('pipeline CLI supports --help', async () => { + const out = execFileSync(process.execPath, [pipelineCli, '--help'], { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + + assert.match(out, /Happier Pipeline/i); + assert.match(out, /Usage:/i); + assert.match(out, /node scripts\/pipeline\/run\.mjs/i); +}); + +test('pipeline CLI supports help <command>', async () => { + const out = execFileSync(process.execPath, [pipelineCli, 'help', 'expo-submit'], { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + + assert.match(out, /\bexpo-submit\b/); + assert.match(out, /\bsubmit\b/i); + assert.match(out, /--environment/); +}); + +test('pipeline CLI supports help for npm-release', async () => { + const out = execFileSync(process.execPath, [pipelineCli, 'help', 'npm-release'], { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + + assert.match(out, /\bnpm-release\b/); + assert.match(out, /--channel/); + assert.match(out, /--publish-cli/); +}); + +test('pipeline CLI supports help for checks', async () => { + const out = execFileSync(process.execPath, [pipelineCli, 'help', 'checks'], { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + + assert.match(out, /\bchecks\b/); + assert.match(out, /--profile/); +}); + +test('pipeline CLI supports <command> --help', async () => { + const out = execFileSync(process.execPath, [pipelineCli, 'expo-submit', '--help'], { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + + assert.match(out, /\bexpo-submit\b/); + assert.match(out, /--path/); +}); + +test('pipeline help covers every supported subcommand', async () => { + const runSource = fs.readFileSync(pipelineCli, 'utf8'); + const allowlist = Array.from(runSource.matchAll(/subcommand\s*!==\s*'([^']+)'/g)).map((m) => String(m[1] ?? '').trim()).filter(Boolean); + const unique = []; + for (const name of allowlist) { + if (!unique.includes(name)) unique.push(name); + } + + const style = createAnsiStyle({ enabled: false }); + for (const cmd of unique) { + const out = renderCommandHelp({ style, command: cmd, cliRelPath: 'scripts/pipeline/run.mjs' }); + assert.doesNotMatch(out, /^Unknown command:/m, `missing help entry for: ${cmd}`); + assert.match(out, new RegExp(`\\b${cmd.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\b`)); + } +}); diff --git a/scripts/release/pipeline_npm_set_preview_versions_write_false.contract.test.mjs b/scripts/release/pipeline_npm_set_preview_versions_write_false.contract.test.mjs new file mode 100644 index 000000000..717654c30 --- /dev/null +++ b/scripts/release/pipeline_npm_set_preview_versions_write_false.contract.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import fs from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +function writeJson(dir, rel, value) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function readJson(dir, rel) { + return JSON.parse(fs.readFileSync(path.join(dir, rel), 'utf8')); +} + +test('set-preview-versions supports --write=false (compute-only, no filesystem changes)', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'happier-preview-versions-')); + writeJson(dir, 'apps/cli/package.json', { name: '@happier-dev/cli', version: '1.2.3' }); + writeJson(dir, 'apps/stack/package.json', { name: '@happier-dev/stack', version: '9.9.9' }); + writeJson(dir, 'packages/relay-server/package.json', { name: '@happier-dev/relay-server', version: '3.4.5' }); + + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'npm', 'set-preview-versions.mjs'), + '--repo-root', + dir, + '--publish-cli', + 'true', + '--publish-stack', + 'true', + '--publish-server', + 'true', + '--server-runner-dir', + 'packages/relay-server', + '--write', + 'false', + ], + { + cwd: repoRoot, + env: { ...process.env, GITHUB_RUN_NUMBER: '123', GITHUB_RUN_ATTEMPT: '2' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ).trim(); + + const parsed = JSON.parse(out); + assert.equal(parsed.cli, '1.2.3-preview.123.2'); + assert.equal(parsed.stack, '9.9.9-preview.123.2'); + assert.equal(parsed.server, '3.4.5-preview.123.2'); + + assert.equal(readJson(dir, 'apps/cli/package.json').version, '1.2.3'); + assert.equal(readJson(dir, 'apps/stack/package.json').version, '9.9.9'); + assert.equal(readJson(dir, 'packages/relay-server/package.json').version, '3.4.5'); +}); + diff --git a/scripts/release/pipeline_promote_branch_cli.contract.test.mjs b/scripts/release/pipeline_promote_branch_cli.contract.test.mjs index 872f09ba4..445735ae9 100644 --- a/scripts/release/pipeline_promote_branch_cli.contract.test.mjs +++ b/scripts/release/pipeline_promote_branch_cli.contract.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,6 +10,9 @@ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..', '..'); test('pipeline CLI can promote branch in dry-run (uses local gh auth)', async () => { + const tmpDir = fs.mkdtempSync(resolve(os.tmpdir(), 'happier-promote-branch-cli-')); + const summaryPath = resolve(tmpDir, 'summary.md'); + const out = execFileSync( process.execPath, [ @@ -21,6 +26,8 @@ test('pipeline CLI can promote branch in dry-run (uses local gh auth)', async () 'fast_forward', '--confirm', 'promote main from dev', + '--summary-file', + summaryPath, '--dry-run', '--secrets-source', 'env', @@ -36,5 +43,10 @@ test('pipeline CLI can promote branch in dry-run (uses local gh auth)', async () assert.match(out, /\[pipeline\] promote branch: dev -> main/); assert.match(out, /\[dry-run\] gh api /); -}); + const summary = fs.readFileSync(summaryPath, 'utf8'); + assert.match(summary, /## Promote Branch/); + assert.match(summary, /- source: `dev`/); + assert.match(summary, /- target: `main`/); + assert.match(summary, /- mode: `fast_forward`/); +}); diff --git a/scripts/release/pipeline_promote_branch_script.test.mjs b/scripts/release/pipeline_promote_branch_script.test.mjs new file mode 100644 index 000000000..553e2658c --- /dev/null +++ b/scripts/release/pipeline_promote_branch_script.test.mjs @@ -0,0 +1,149 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const repoRoot = path.resolve(import.meta.dirname, '..', '..'); +const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'github', 'promote-branch.mjs'); + +function writeExecutable(filePath, content) { + fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o700 }); +} + +function writeGhStub(binDir) { + const ghPath = path.join(binDir, 'gh'); + writeExecutable( + ghPath, + [ + '#!/usr/bin/env node', + "import fs from 'node:fs';", + '', + 'const logPath = process.env.GH_STUB_LOG;', + 'if (logPath) fs.appendFileSync(logPath, `${JSON.stringify(process.argv.slice(2))}\\n`, \"utf8\");', + '', + 'const args = process.argv.slice(2);', + "if (args[0] !== 'api') process.exit(0);", + '', + "let method = 'GET';", + 'let endpoint = "";', + 'const rawFields = [];', + 'const typedFields = [];', + '', + 'for (let i = 1; i < args.length; i++) {', + ' const a = args[i];', + " if (a === '-X' || a === '--method') { method = args[i + 1] ?? method; i++; continue; }", + ' if ((a === "-f" || a === "--raw-field") && args[i + 1]) { rawFields.push(args[i + 1]); i++; continue; }', + ' if ((a === "-F" || a === "--field") && args[i + 1]) { typedFields.push(args[i + 1]); i++; continue; }', + ' if (!endpoint && !a.startsWith("-")) endpoint = a;', + '}', + '', + 'function hasTypedForceTrue() {', + ' return typedFields.some((f) => f === "force=true");', + '}', + '', + 'function write422(message) {', + ' process.stdout.write(JSON.stringify({ message, status: "422" }));', + ' process.stderr.write(`gh: ${message} (HTTP 422)\\n`);', + ' process.exit(1);', + '}', + '', + 'function write403(message) {', + ' process.stdout.write(JSON.stringify({ message, status: "403" }));', + ' process.stderr.write(`gh: ${message} (HTTP 403)\\n`);', + ' process.exit(1);', + '}', + '', + 'if (method === "GET") {', + ' if (endpoint.includes("/git/ref/heads/dev")) { process.stdout.write("SOURCE_SHA\\n"); process.exit(0); }', + ' if (endpoint.includes("/git/ref/heads/main")) { process.stdout.write("TARGET_SHA\\n"); process.exit(0); }', + ' if (endpoint.includes("/compare/main...dev")) {', + ' process.stdout.write(JSON.stringify({ status: "ahead", ahead_by: 1, behind_by: 0, files: [] }));', + ' process.exit(0);', + ' }', + ' process.stdout.write("");', + ' process.exit(0);', + '}', + '', + 'if (method === "PATCH" && endpoint.includes("/git/refs/heads/main")) {', + ' const outcome = process.env.GH_STUB_PATCH_OUTCOME ?? "require_typed_force";', + ' if (outcome === "forbidden") write403("Forbidden");', + ' if (!hasTypedForceTrue()) write422("Update is not a fast forward");', + ' process.exit(0);', + '}', + '', + 'if (method === "POST" && endpoint.endsWith("/git/refs")) {', + ' const message = process.env.GH_STUB_CREATE_MESSAGE ?? "Reference already exists";', + ' write422(message);', + '}', + '', + 'process.exit(0);', + '', + ].join('\n'), + ); + return ghPath; +} + +function runPromoteBranch({ patchOutcome }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'happier-promote-branch-script-')); + const binDir = path.join(dir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + const logPath = path.join(dir, 'gh.log'); + writeGhStub(binDir); + + const env = { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + GH_REPO: 'happier-dev/happier', + GH_TOKEN: 'test-token', + GH_STUB_LOG: logPath, + GH_STUB_PATCH_OUTCOME: patchOutcome ?? 'require_typed_force', + }; + + const res = spawnSync( + process.execPath, + [ + scriptPath, + '--source', + 'dev', + '--target', + 'main', + '--mode', + 'reset', + '--allow-reset', + 'true', + '--confirm', + 'reset main from dev', + ], + { cwd: repoRoot, env, encoding: 'utf8' }, + ); + + const calls = fs + .readFileSync(logPath, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + + return { res, calls }; +} + +test('promote-branch reset uses typed force update (no fallback create)', () => { + const { res, calls } = runPromoteBranch({ patchOutcome: 'require_typed_force' }); + + assert.equal(res.status, 0, `expected success (stderr: ${res.stderr})`); + assert.ok(calls.some((c) => c.includes('-X') && c.includes('PATCH')), 'expected PATCH call to update ref'); + assert.ok(calls.some((c) => c.includes('-F') && c.includes('force=true')), 'expected typed force=true field'); + assert.ok(!calls.some((c) => c.includes('-X') && c.includes('POST')), 'expected no POST fallback create call'); +}); + +test('promote-branch does not mask PATCH failures by attempting create', () => { + const { res, calls } = runPromoteBranch({ patchOutcome: 'forbidden' }); + + assert.notEqual(res.status, 0, 'expected failure'); + assert.match(res.stderr, /\bForbidden\b/); + assert.ok(!calls.some((c) => c.includes('-X') && c.includes('POST')), 'expected no POST fallback create call'); +}); + diff --git a/scripts/release/pipeline_promote_deploy_branch_cli.contract.test.mjs b/scripts/release/pipeline_promote_deploy_branch_cli.contract.test.mjs index 17aa28801..21cb6a9a7 100644 --- a/scripts/release/pipeline_promote_deploy_branch_cli.contract.test.mjs +++ b/scripts/release/pipeline_promote_deploy_branch_cli.contract.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,6 +10,9 @@ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..', '..'); test('pipeline CLI can promote deploy branch in dry-run', async () => { + const tmpDir = fs.mkdtempSync(resolve(os.tmpdir(), 'happier-promote-deploy-branch-')); + const summaryPath = resolve(tmpDir, 'summary.md'); + const out = execFileSync( process.execPath, [ @@ -19,6 +24,8 @@ test('pipeline CLI can promote deploy branch in dry-run', async () => { 'server', '--source-ref', 'dev', + '--summary-file', + summaryPath, '--dry-run', '--secrets-source', 'env', @@ -37,4 +44,8 @@ test('pipeline CLI can promote deploy branch in dry-run', async () => { assert.match(out, /deploy%2Fproduction%2Fserver/, 'gh api ref path must URL-encode deploy branch slashes'); assert.match(out, /-F force=true/, 'gh api PATCH should send boolean force with --field (typed)'); assert.match(out, /-X PATCH/, 'dry-run should print the intended PATCH update call'); + + const summary = fs.readFileSync(summaryPath, 'utf8'); + assert.match(summary, /^## Promote deploy branch/m); + assert.match(summary, /target: `deploy\/production\/server`/); }); diff --git a/scripts/release/pipeline_publish_binary_releases_cli.contract.test.mjs b/scripts/release/pipeline_publish_binary_releases_cli.contract.test.mjs new file mode 100644 index 000000000..7098b083f --- /dev/null +++ b/scripts/release/pipeline_publish_binary_releases_cli.contract.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('pipeline CLI can run publish-cli-binaries dry-run using env-file mode', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'run.mjs'), + 'publish-cli-binaries', + '--channel', + 'preview', + '--dry-run', + '--secrets-source', + 'env', + ], + { + cwd: repoRoot, + env: { + ...process.env, + MINISIGN_SECRET_KEY: 'untrusted comment: minisign encrypted secret key\nRWQpH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1', + MINISIGN_PASSPHRASE: 'x', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\[pipeline\] exec: node .*publish-cli-binaries\.mjs/); + assert.match(out, /"--channel"/); + assert.match(out, /"preview"/); +}); + +test('pipeline CLI can run publish-hstack-binaries dry-run using env-file mode', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'run.mjs'), + 'publish-hstack-binaries', + '--channel', + 'preview', + '--dry-run', + '--secrets-source', + 'env', + ], + { + cwd: repoRoot, + env: { + ...process.env, + MINISIGN_SECRET_KEY: 'untrusted comment: minisign encrypted secret key\nRWQpH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1', + MINISIGN_PASSPHRASE: 'x', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\[pipeline\] exec: node .*publish-hstack-binaries\.mjs/); + assert.match(out, /"--channel"/); + assert.match(out, /"preview"/); +}); + diff --git a/scripts/release/pipeline_publish_server_runtime_cli.contract.test.mjs b/scripts/release/pipeline_publish_server_runtime_cli.contract.test.mjs index e4fe40c2c..4ae603801 100644 --- a/scripts/release/pipeline_publish_server_runtime_cli.contract.test.mjs +++ b/scripts/release/pipeline_publish_server_runtime_cli.contract.test.mjs @@ -36,5 +36,6 @@ test('pipeline CLI can publish server-runtime rolling release in dry-run', async assert.match(out, /\[pipeline\] server-runtime: channel=preview tag=server-preview/); assert.match(out, /scripts\/pipeline\/release\/publish-server-runtime\.mjs/); + // Manifests embed absolute GitHub release URLs; ensure we never emit a double-slash repo placeholder. + assert.doesNotMatch(out, /https:\/\/github\.com\/\/releases\//); }); - diff --git a/scripts/release/pipeline_release_cli.contract.test.mjs b/scripts/release/pipeline_release_cli.contract.test.mjs index f26cd51a0..4267e6ea3 100644 --- a/scripts/release/pipeline_release_cli.contract.test.mjs +++ b/scripts/release/pipeline_release_cli.contract.test.mjs @@ -16,9 +16,11 @@ test('pipeline CLI can run release deploy dry-run (promote deploy branches + tri '--confirm', 'release preview from dev', '--deploy-environment', - 'production', + 'preview', '--deploy-targets', 'server', + '--force-deploy', + 'true', '--repository', 'happier-dev/happier', '--dry-run', @@ -44,8 +46,7 @@ test('pipeline CLI can run release deploy dry-run (promote deploy branches + tri }, ); - assert.match(out, /\[pipeline\] release: action=release preview from dev/); - assert.match(out, /\[pipeline\] promote deploy branch: deploy\/production\/server <= dev/); - assert.match(out, /Dokploy webhook target: ref=refs\/heads\/deploy\/production\/server/); + assert.match(out, /\[pipeline\] release: environment=preview confirm=release preview from dev/); + assert.match(out, /\[pipeline\] dry-run: would run/); + assert.match(out, /- runDeployServer: true/); }); - diff --git a/scripts/release/pipeline_release_cli_preview_publishers.contract.test.mjs b/scripts/release/pipeline_release_cli_preview_publishers.contract.test.mjs index fdd59370c..6e99a2aaf 100644 --- a/scripts/release/pipeline_release_cli_preview_publishers.contract.test.mjs +++ b/scripts/release/pipeline_release_cli_preview_publishers.contract.test.mjs @@ -16,9 +16,11 @@ test('pipeline CLI release can include preview publishers (docker + ui-web + ser '--confirm', 'release preview from dev', '--deploy-environment', - 'production', + 'preview', '--deploy-targets', 'ui,server,server_runner', + '--force-deploy', + 'true', '--repository', 'happier-dev/happier', '--dry-run', @@ -45,8 +47,9 @@ test('pipeline CLI release can include preview publishers (docker + ui-web + ser }, ); - assert.match(out, /\[pipeline\] docker publish: channel=preview/); - assert.match(out, /\[pipeline\] ui-web: channel=preview tag=ui-web-preview/); - assert.match(out, /\[pipeline\] server-runtime: channel=preview tag=server-preview/); + assert.match(out, /\[pipeline\] release: environment=preview confirm=release preview from dev/); + assert.match(out, /\[pipeline\] dry-run: would run/); + assert.match(out, /- runPublishDocker: true/); + assert.match(out, /- runPublishUiWeb: true/); + assert.match(out, /- runPublishServerRuntime: true/); }); - diff --git a/scripts/release/pipeline_release_cli_with_npm.contract.test.mjs b/scripts/release/pipeline_release_cli_with_npm.contract.test.mjs index 6dd482ff9..f8aeeaf92 100644 --- a/scripts/release/pipeline_release_cli_with_npm.contract.test.mjs +++ b/scripts/release/pipeline_release_cli_with_npm.contract.test.mjs @@ -16,13 +16,13 @@ test('pipeline CLI release can include npm publish lane in dry-run', async () => '--confirm', 'release preview from dev', '--deploy-environment', - 'production', + 'preview', '--deploy-targets', - 'server', + 'cli', + '--force-deploy', + 'true', '--repository', 'happier-dev/happier', - '--npm-targets', - 'cli', '--npm-mode', 'pack+publish', '--dry-run', @@ -49,8 +49,7 @@ test('pipeline CLI release can include npm publish lane in dry-run', async () => }, ); - assert.match(out, /\[pipeline\] release: npm channel=preview targets=cli/); - assert.match(out, /scripts\/pipeline\/npm\/release-packages\.mjs/); - assert.match(out, /scripts\/pipeline\/npm\/publish-tarball\.mjs/); + assert.match(out, /\[pipeline\] release: environment=preview confirm=release preview from dev/); + assert.match(out, /\[pipeline\] dry-run: would run/); + assert.match(out, /- runPublishNpm: true/); }); - diff --git a/scripts/release/pipeline_release_preview_publishes_binary_releases.contract.test.mjs b/scripts/release/pipeline_release_preview_publishes_binary_releases.contract.test.mjs new file mode 100644 index 000000000..49632931d --- /dev/null +++ b/scripts/release/pipeline_release_preview_publishes_binary_releases.contract.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('release preview from dev can dry-run binary releases for cli + hstack', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'run.mjs'), + 'release', + '--confirm', + 'release preview from dev', + '--repository', + 'happier-dev/happier', + '--deploy-environment', + 'preview', + '--deploy-targets', + 'cli,stack', + '--npm-mode', + 'pack', + '--dry-run', + '--secrets-source', + 'env', + ], + { + cwd: repoRoot, + env: { + ...process.env, + MINISIGN_SECRET_KEY: 'untrusted comment: minisign encrypted secret key\nRWQpH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1', + MINISIGN_PASSPHRASE: 'x', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\[pipeline\] preview version suffix: preview\./); + assert.match(out, /\[pipeline\] dry-run: would run/); + assert.match(out, /- runPublishCliBinaries: true/); + assert.match(out, /- runPublishHstackBinaries: true/); +}); diff --git a/scripts/release/pipeline_run_github_audit_release_assets.contract.test.mjs b/scripts/release/pipeline_run_github_audit_release_assets.contract.test.mjs new file mode 100644 index 000000000..a159a4557 --- /dev/null +++ b/scripts/release/pipeline_run_github_audit_release_assets.contract.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +function run(args) { + return spawnSync(process.execPath, [resolve(repoRoot, 'scripts', 'pipeline', 'run.mjs'), ...args], { + cwd: repoRoot, + encoding: 'utf8', + }); +} + +test('pipeline run exposes github-audit-release-assets', async () => { + const res = run([ + 'github-audit-release-assets', + '--tag', + 'cli-preview', + '--kind', + 'cli', + '--version', + '1.2.3-preview.123.2', + '--targets', + 'linux-x64', + '--assets-json', + JSON.stringify([ + 'happier-v1.2.3-preview.123.2-linux-x64.tar.gz', + 'checksums-happier-v1.2.3-preview.123.2.txt', + 'checksums-happier-v1.2.3-preview.123.2.txt.minisig', + ]), + ]); + + assert.equal(res.status, 0, `expected exit 0, got ${res.status} stderr=${res.stderr}`); +}); + diff --git a/scripts/release/pipeline_run_github_commit_and_push.contract.test.mjs b/scripts/release/pipeline_run_github_commit_and_push.contract.test.mjs new file mode 100644 index 000000000..9aa002ab6 --- /dev/null +++ b/scripts/release/pipeline_run_github_commit_and_push.contract.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +function run(args) { + return spawnSync(process.execPath, [path.join(repoRoot, 'scripts', 'pipeline', 'run.mjs'), ...args], { + cwd: repoRoot, + encoding: 'utf8', + }); +} + +test('pipeline run exposes github-commit-and-push (dry-run)', () => { + const res = run([ + 'github-commit-and-push', + '--paths', + 'package.json', + '--message', + 'test commit (dry-run)', + '--push-mode', + 'never', + '--dry-run', + ]); + assert.equal(res.status, 0, `expected exit 0, got ${res.status} stderr=${res.stderr}`); +}); + diff --git a/scripts/release/pipeline_run_npm_set_preview_versions.contract.test.mjs b/scripts/release/pipeline_run_npm_set_preview_versions.contract.test.mjs new file mode 100644 index 000000000..c4fe4ceeb --- /dev/null +++ b/scripts/release/pipeline_run_npm_set_preview_versions.contract.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import fs from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +function writeJson(dir, rel, value) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function readJson(dir, rel) { + return JSON.parse(fs.readFileSync(path.join(dir, rel), 'utf8')); +} + +test('pipeline run exposes npm-set-preview-versions (write=false compute-only)', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'happier-preview-versions-')); + writeJson(dir, 'apps/cli/package.json', { name: '@happier-dev/cli', version: '1.2.3' }); + + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'run.mjs'), + 'npm-set-preview-versions', + '--repo-root', + dir, + '--publish-cli', + 'true', + '--publish-stack', + 'false', + '--publish-server', + 'false', + '--write', + 'false', + ], + { + cwd: repoRoot, + env: { ...process.env, GITHUB_RUN_NUMBER: '123', GITHUB_RUN_ATTEMPT: '2' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ).trim(); + + const parsed = JSON.parse(out); + assert.equal(parsed.cli, '1.2.3-preview.123.2'); + assert.equal(readJson(dir, 'apps/cli/package.json').version, '1.2.3'); +}); + diff --git a/scripts/release/pipeline_run_release_wrapped_passthrough.contract.test.mjs b/scripts/release/pipeline_run_release_wrapped_passthrough.contract.test.mjs new file mode 100644 index 000000000..d65a380f3 --- /dev/null +++ b/scripts/release/pipeline_run_release_wrapped_passthrough.contract.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('run.mjs forwards unknown flags to wrapped release scripts (dry-run)', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'run.mjs'), + 'release-build-cli-binaries', + '--dry-run', + '--channel', + 'preview', + '--version', + '0.0.0-preview.test.1', + '--targets', + 'linux-arm64', + ], + { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /build-cli-binaries\.mjs/); + assert.match(out, /"--channel"/); + assert.match(out, /"preview"/); + assert.match(out, /"--version"/); + assert.match(out, /0\.0\.0-preview\.test\.1/); +}); + diff --git a/scripts/release/pipeline_run_smoke_cli.contract.test.mjs b/scripts/release/pipeline_run_smoke_cli.contract.test.mjs new file mode 100644 index 000000000..5fa5d976a --- /dev/null +++ b/scripts/release/pipeline_run_smoke_cli.contract.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('pipeline CLI exposes smoke-cli subcommand', async () => { + const out = execFileSync( + process.execPath, + [resolve(repoRoot, 'scripts', 'pipeline', 'run.mjs'), 'smoke-cli', '--dry-run'], + { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /scripts\/pipeline\/smoke\/cli-smoke\.mjs/); + assert.match(out, /\bCLI smoke test passed\b/); +}); diff --git a/scripts/release/pipeline_run_tauri_build_steps.contract.test.mjs b/scripts/release/pipeline_run_tauri_build_steps.contract.test.mjs new file mode 100644 index 000000000..3ee4eb5b8 --- /dev/null +++ b/scripts/release/pipeline_run_tauri_build_steps.contract.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +function run(args, env = {}) { + return spawnSync(process.execPath, [path.join(repoRoot, 'scripts', 'pipeline', 'run.mjs'), ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, ...env }, + }); +} + +test('pipeline run exposes tauri-build-updater-artifacts (dry-run)', () => { + const res = run( + [ + 'tauri-build-updater-artifacts', + '--environment', + 'preview', + '--build-version', + '0.0.0-preview.1', + '--tauri-target', + 'x86_64-unknown-linux-gnu', + '--ui-dir', + 'apps/ui', + '--dry-run', + ], + { + TAURI_SIGNING_PRIVATE_KEY: '/tmp/tauri.signing.key', + APPLE_SIGNING_IDENTITY: 'Developer ID Application: Dummy', + }, + ); + assert.equal(res.status, 0, `expected exit 0, got ${res.status} stderr=${res.stderr}`); +}); + +test('pipeline run exposes tauri-notarize-macos-artifacts (dry-run)', () => { + const res = run( + [ + 'tauri-notarize-macos-artifacts', + '--ui-dir', + 'apps/ui', + '--tauri-target', + 'aarch64-apple-darwin', + '--dry-run', + ], + ); + assert.equal(res.status, 0, `expected exit 0, got ${res.status} stderr=${res.stderr}`); +}); + +test('pipeline run exposes tauri-collect-updater-artifacts (dry-run)', () => { + const res = run( + [ + 'tauri-collect-updater-artifacts', + '--environment', + 'preview', + '--platform-key', + 'linux-x64', + '--ui-version', + '0.0.0', + '--tauri-target', + 'x86_64-unknown-linux-gnu', + '--ui-dir', + 'apps/ui', + '--dry-run', + ], + ); + assert.equal(res.status, 0, `expected exit 0, got ${res.status} stderr=${res.stderr}`); +}); + diff --git a/scripts/release/pipeline_run_tauri_validate_updater_pubkey.contract.test.mjs b/scripts/release/pipeline_run_tauri_validate_updater_pubkey.contract.test.mjs new file mode 100644 index 000000000..df544f6d8 --- /dev/null +++ b/scripts/release/pipeline_run_tauri_validate_updater_pubkey.contract.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +function run(args) { + return spawnSync(process.execPath, [path.join(repoRoot, 'scripts', 'pipeline', 'run.mjs'), ...args], { + cwd: repoRoot, + encoding: 'utf8', + }); +} + +test('pipeline run exposes tauri-validate-updater-pubkey', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'happier-tauri-pubkey-')); + const configPath = path.join(dir, 'tauri.conf.json'); + + await writeFile( + configPath, + JSON.stringify( + { + plugins: { + updater: { + pubkey: '-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr\\n-----END PUBLIC KEY-----', + }, + }, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + const res = run(['tauri-validate-updater-pubkey', '--config-path', configPath]); + assert.equal(res.status, 0, `expected exit 0, got ${res.status} stderr=${res.stderr}`); +}); + diff --git a/scripts/release/pipeline_run_testing_create_auth_credentials.contract.test.mjs b/scripts/release/pipeline_run_testing_create_auth_credentials.contract.test.mjs new file mode 100644 index 000000000..7db949e90 --- /dev/null +++ b/scripts/release/pipeline_run_testing_create_auth_credentials.contract.test.mjs @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtemp, readFile, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +/** + * @param {string[]} args + * @param {Record<string, string>} [env] + * @returns {Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }>} + */ +function run(args, env = {}) { + return new Promise((resolvePromise) => { + const child = spawn(process.execPath, [path.join(repoRoot, 'scripts', 'pipeline', 'run.mjs'), ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('close', (code, signal) => { + resolvePromise({ code, signal, stdout, stderr }); + }); + }); +} + +test('pipeline run exposes testing-create-auth-credentials and writes access keys', async () => { + /** @type {Set<import('node:net').Socket>} */ + const sockets = new Set(); + + const server = http.createServer((req, res) => { + if (req.method !== 'POST' || req.url !== '/v1/auth') { + res.statusCode = 404; + res.end('not found'); + return; + } + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + try { + JSON.parse(raw); + } catch { + res.statusCode = 400; + res.end('bad json'); + return; + } + res.setHeader('content-type', 'application/json'); + res.setHeader('connection', 'close'); + res.end(JSON.stringify({ token: 'test-token' })); + }); + }); + + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => { + sockets.delete(socket); + }); + }); + + await new Promise((resolvePromise, reject) => { + server.listen(0, '127.0.0.1', () => resolvePromise()); + server.on('error', reject); + }); + + const address = server.address(); + assert.ok(address && typeof address === 'object', 'expected server.address() to be an object'); + const serverUrl = `http://127.0.0.1:${address.port}`; + + const homeDir = await mkdtemp(path.join(tmpdir(), 'happier-auth-')); + const secretBase64 = Buffer.alloc(32, 7).toString('base64'); + + try { + const res = await run( + [ + 'testing-create-auth-credentials', + '--server-url', + serverUrl, + '--home-dir', + homeDir, + '--secret-base64', + secretBase64, + ], + ); + assert.equal(res.code, 0, `expected exit 0, got ${res.code} stderr=${res.stderr}`); + + const rootKeyPath = join(homeDir, 'access.key'); + const rootData = JSON.parse(await readFile(rootKeyPath, 'utf8')); + assert.equal(rootData.token, 'test-token'); + assert.equal(rootData.secret, secretBase64); + + assert.ok(typeof rootData === 'object'); + const serversDir = join(homeDir, 'servers'); + const entries = await readdir(serversDir, { withFileTypes: true }); + const serverEntry = entries.find((e) => e.isDirectory()); + assert.ok(serverEntry, 'expected an active server id directory under homeDir/servers'); + + const scopedData = JSON.parse(await readFile(join(serversDir, serverEntry.name, 'access.key'), 'utf8')); + assert.equal(scopedData.token, 'test-token'); + assert.equal(scopedData.secret, secretBase64); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolvePromise) => server.close(() => resolvePromise())); + } +}); diff --git a/scripts/release/pipeline_smoke_cli.contract.test.mjs b/scripts/release/pipeline_smoke_cli.contract.test.mjs new file mode 100644 index 000000000..91fd2c2f7 --- /dev/null +++ b/scripts/release/pipeline_smoke_cli.contract.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('pipeline CLI smoke script supports --dry-run', async () => { + const out = execFileSync( + process.execPath, + [resolve(repoRoot, 'scripts', 'pipeline', 'smoke', 'cli-smoke.mjs'), '--dry-run'], + { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\bnpm pack\b/); + assert.match(out, /\bnpm install\b/); + assert.match(out, /\bhappier\b.*--help/); + assert.match(out, /\bhappier\b.*--version/); +}); + diff --git a/scripts/release/pipeline_tauri_build_updater_artifacts.contract.test.mjs b/scripts/release/pipeline_tauri_build_updater_artifacts.contract.test.mjs new file mode 100644 index 000000000..b727436f3 --- /dev/null +++ b/scripts/release/pipeline_tauri_build_updater_artifacts.contract.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('tauri build-updater-artifacts script supports preview dry-run', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'tauri', 'build-updater-artifacts.mjs'), + '--environment', + 'preview', + '--build-version', + '1.2.3-preview.123', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\byarn tauri build\b/); + assert.match(out, /tauri\.preview\.conf\.json/); + assert.match(out, /tauri\.version\.override\.json/); +}); + +test('tauri build-updater-artifacts script supports production dry-run', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'tauri', 'build-updater-artifacts.mjs'), + '--environment', + 'production', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\byarn tauri build\b/); + assert.doesNotMatch(out, /tauri\.preview\.conf\.json/); +}); + diff --git a/scripts/release/pipeline_tauri_collect_updater_artifacts.contract.test.mjs b/scripts/release/pipeline_tauri_collect_updater_artifacts.contract.test.mjs new file mode 100644 index 000000000..716cb4243 --- /dev/null +++ b/scripts/release/pipeline_tauri_collect_updater_artifacts.contract.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('tauri collect-updater-artifacts script supports dry-run', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'tauri', 'collect-updater-artifacts.mjs'), + '--environment', + 'preview', + '--platform-key', + 'darwin-aarch64', + '--ui-version', + '1.2.3', + '--tauri-target', + 'aarch64-apple-darwin', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /dist\/tauri\/updates\/darwin-aarch64/); + assert.match(out, /happier-ui-desktop-preview-darwin-aarch64/); +}); + diff --git a/scripts/release/pipeline_tauri_notarize_macos_artifacts.contract.test.mjs b/scripts/release/pipeline_tauri_notarize_macos_artifacts.contract.test.mjs new file mode 100644 index 000000000..168c372a8 --- /dev/null +++ b/scripts/release/pipeline_tauri_notarize_macos_artifacts.contract.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('tauri notarize-macos-artifacts script supports dry-run', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'tauri', 'notarize-macos-artifacts.mjs'), + '--ui-dir', + 'apps/ui', + '--tauri-target', + 'aarch64-apple-darwin', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /\bxcrun notarytool submit\b/); + assert.match(out, /\btauri signer sign\b/); +}); + diff --git a/scripts/release/pipeline_ui_mobile_release_environment_profile_guard.contract.test.mjs b/scripts/release/pipeline_ui_mobile_release_environment_profile_guard.contract.test.mjs new file mode 100644 index 000000000..7ed72ba86 --- /dev/null +++ b/scripts/release/pipeline_ui_mobile_release_environment_profile_guard.contract.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const repoRoot = path.resolve(import.meta.dirname, '..', '..'); + +test('ui-mobile-release rejects environment/profile mismatches (production env with preview profile)', () => { + assert.throws( + () => + execFileSync( + process.execPath, + [ + path.join(repoRoot, 'scripts', 'pipeline', 'run.mjs'), + 'ui-mobile-release', + '--environment', + 'production', + '--action', + 'native', + '--platform', + 'ios', + '--profile', + 'preview', + '--native-build-mode', + 'local', + '--dry-run', + '--secrets-source', + 'env', + ], + { + cwd: repoRoot, + env: { ...process.env, EXPO_TOKEN: '' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ), + (err) => { + assert.equal(typeof err, 'object'); + const stderr = /** @type {any} */ (err).stderr?.toString?.() ?? ''; + assert.match(stderr, /--profile/i); + assert.match(stderr, /production/i); + assert.match(stderr, /preview/i); + return true; + }, + ); +}); + diff --git a/scripts/release/promote_branch.workflow.contract.test.mjs b/scripts/release/promote_branch.workflow.contract.test.mjs index 777ad5554..a5cb94f83 100644 --- a/scripts/release/promote_branch.workflow.contract.test.mjs +++ b/scripts/release/promote_branch.workflow.contract.test.mjs @@ -14,6 +14,5 @@ async function loadWorkflow(name) { test('promote-branch delegates branch updates to pipeline script', async () => { const raw = await loadWorkflow('promote-branch.yml'); assert.match(raw, /actions\/create-github-app-token@v1/); - assert.match(raw, /node scripts\/pipeline\/github\/promote-branch\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs promote-branch/); }); - diff --git a/scripts/release/promote_docs_deploy_branch.workflow.contract.test.mjs b/scripts/release/promote_docs_deploy_branch.workflow.contract.test.mjs index 1b68e558b..58d3c43bb 100644 --- a/scripts/release/promote_docs_deploy_branch.workflow.contract.test.mjs +++ b/scripts/release/promote_docs_deploy_branch.workflow.contract.test.mjs @@ -13,7 +13,7 @@ async function loadWorkflow(name) { test('promote-docs delegates deploy branch promotion to pipeline script', async () => { const raw = await loadWorkflow('promote-docs.yml'); - assert.match(raw, /node scripts\/pipeline\/github\/promote-deploy-branch\.mjs/); - assert.match(raw, /node scripts\/pipeline\/deploy\/trigger-webhooks\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs promote-deploy-branch/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs deploy/); assert.doesNotMatch(raw, /Wait for deploy workflow/i); }); diff --git a/scripts/release/promote_server_deploy_branch.workflow.contract.test.mjs b/scripts/release/promote_server_deploy_branch.workflow.contract.test.mjs index 68c9900db..18205ba3a 100644 --- a/scripts/release/promote_server_deploy_branch.workflow.contract.test.mjs +++ b/scripts/release/promote_server_deploy_branch.workflow.contract.test.mjs @@ -13,7 +13,7 @@ async function loadWorkflow(name) { test('promote-server delegates deploy branch promotion to pipeline script', async () => { const raw = await loadWorkflow('promote-server.yml'); - assert.match(raw, /node scripts\/pipeline\/github\/promote-deploy-branch\.mjs/); - assert.match(raw, /node scripts\/pipeline\/deploy\/trigger-webhooks\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs promote-deploy-branch/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs deploy/); assert.doesNotMatch(raw, /Wait for deploy workflow/i); }); diff --git a/scripts/release/promote_server_runtime_release.workflow.contract.test.mjs b/scripts/release/promote_server_runtime_release.workflow.contract.test.mjs index 6c516fe4e..14e842a4f 100644 --- a/scripts/release/promote_server_runtime_release.workflow.contract.test.mjs +++ b/scripts/release/promote_server_runtime_release.workflow.contract.test.mjs @@ -13,8 +13,7 @@ async function loadWorkflow(name) { test('promote-server delegates GitHub release publishing to pipeline script', async () => { const raw = await loadWorkflow('promote-server.yml'); - assert.match(raw, /node scripts\/pipeline\/github\/publish-release\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs github-publish-release/); assert.doesNotMatch(raw, /gh release upload/, 'promote-server should not embed gh release upload'); assert.doesNotMatch(raw, /gh release create/, 'promote-server should not embed gh release create'); }); - diff --git a/scripts/release/promote_ui_deploy_branch.workflow.contract.test.mjs b/scripts/release/promote_ui_deploy_branch.workflow.contract.test.mjs index 6ec16bd25..4610bfcf7 100644 --- a/scripts/release/promote_ui_deploy_branch.workflow.contract.test.mjs +++ b/scripts/release/promote_ui_deploy_branch.workflow.contract.test.mjs @@ -14,7 +14,7 @@ async function loadWorkflow(name) { test('promote-ui delegates web deploy branch promotion to pipeline script', async () => { const raw = await loadWorkflow('promote-ui.yml'); assert.match(raw, /Promote source ref to deploy branch \(web\)/); - assert.match(raw, /node scripts\/pipeline\/github\/promote-deploy-branch\.mjs/); - assert.match(raw, /node scripts\/pipeline\/deploy\/trigger-webhooks\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs promote-deploy-branch/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs deploy/); assert.doesNotMatch(raw, /Wait for deploy workflow/i); }); diff --git a/scripts/release/promote_website_deploy_branch.workflow.contract.test.mjs b/scripts/release/promote_website_deploy_branch.workflow.contract.test.mjs index b580654fd..ecca1bb86 100644 --- a/scripts/release/promote_website_deploy_branch.workflow.contract.test.mjs +++ b/scripts/release/promote_website_deploy_branch.workflow.contract.test.mjs @@ -13,7 +13,7 @@ async function loadWorkflow(name) { test('promote-website delegates deploy branch promotion to pipeline script', async () => { const raw = await loadWorkflow('promote-website.yml'); - assert.match(raw, /node scripts\/pipeline\/github\/promote-deploy-branch\.mjs/); - assert.match(raw, /node scripts\/pipeline\/deploy\/trigger-webhooks\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs promote-deploy-branch/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs deploy/); assert.doesNotMatch(raw, /Wait for deploy workflow/i); }); diff --git a/scripts/release/publish_cli_binaries_version_tags.contract.test.mjs b/scripts/release/publish_cli_binaries_version_tags.contract.test.mjs new file mode 100644 index 000000000..1ff239507 --- /dev/null +++ b/scripts/release/publish_cli_binaries_version_tags.contract.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('publish-cli-binaries pipeline publishes cli-v* version tags alongside rolling tags (dry-run)', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'release', 'publish-cli-binaries.mjs'), + '--channel', + 'preview', + '--allow-stable', + 'false', + '--run-contracts', + 'false', + '--check-installers', + 'false', + '--dry-run', + ], + { + cwd: repoRoot, + env: { + ...process.env, + GH_TOKEN: '', + GH_REPO: '', + GITHUB_REPOSITORY: '', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /--tag\s+cli-preview\b/); + assert.match(out, /--tag\s+cli-v/); + assert.match(out, /clean artifacts dir: dist\/release-assets\/cli|ensure clean artifacts dir: dist\/release-assets\/cli/i); +}); + +test('publish-cli-binaries fails fast with helpful message when MINISIGN_SECRET_KEY is invalid', async () => { + const scriptPath = resolve(repoRoot, 'scripts', 'pipeline', 'release', 'publish-cli-binaries.mjs'); + const result = spawnSync( + process.execPath, + [ + scriptPath, + '--channel', + 'preview', + '--allow-stable', + 'false', + '--run-contracts', + 'false', + '--check-installers', + 'false', + ], + { + cwd: repoRoot, + env: { + ...process.env, + MINISIGN_SECRET_KEY: 'RWQpH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1', + MINISIGN_PASSPHRASE: 'x', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.notEqual(result.status, 0, 'expected publish-cli-binaries to fail for invalid minisign key'); + const stderr = String(result.stderr ?? ''); + assert.match(stderr, /MINISIGN_SECRET_KEY/i); + assert.match(stderr, /truncated|dotenv|multiline|file|path/i); + assert.doesNotMatch(String(result.stdout ?? ''), /build-cli-binaries\.mjs/i, 'should fail before running the heavy build'); +}); + diff --git a/scripts/release/publish_github_release.workflow.contract.test.mjs b/scripts/release/publish_github_release.workflow.contract.test.mjs index 67e17b12f..bdb1a16ba 100644 --- a/scripts/release/publish_github_release.workflow.contract.test.mjs +++ b/scripts/release/publish_github_release.workflow.contract.test.mjs @@ -29,7 +29,7 @@ test('publish-github-release uses release bot GitHub App token for rolling tag u ); assert.match(raw, /actions\/create-github-app-token@v1/, 'publish-github-release must use actions/create-github-app-token@v1'); - assert.match(raw, /node scripts\/pipeline\/github\/publish-release\.mjs/, 'publish-github-release must delegate to pipeline script'); + assert.match(raw, /node scripts\/pipeline\/run\.mjs github-publish-release/, 'publish-github-release must delegate to pipeline script'); assert.match( raw, /persist-credentials:\s*false/, diff --git a/scripts/release/publish_hstack_binaries_version_tags.contract.test.mjs b/scripts/release/publish_hstack_binaries_version_tags.contract.test.mjs new file mode 100644 index 000000000..326f04a0c --- /dev/null +++ b/scripts/release/publish_hstack_binaries_version_tags.contract.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('publish-hstack-binaries pipeline publishes stack-v* version tags alongside rolling tags (dry-run)', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'release', 'publish-hstack-binaries.mjs'), + '--channel', + 'preview', + '--allow-stable', + 'false', + '--run-contracts', + 'false', + '--check-installers', + 'false', + '--dry-run', + ], + { + cwd: repoRoot, + env: { + ...process.env, + GH_TOKEN: '', + GH_REPO: '', + GITHUB_REPOSITORY: '', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /--tag\s+stack-preview\b/); + assert.match(out, /--tag\s+stack-v/); + assert.match(out, /clean artifacts dir: dist\/release-assets\/stack|ensure clean artifacts dir: dist\/release-assets\/stack/i); +}); + +test('publish-hstack-binaries fails fast with helpful message when MINISIGN_SECRET_KEY is invalid', async () => { + const scriptPath = resolve(repoRoot, 'scripts', 'pipeline', 'release', 'publish-hstack-binaries.mjs'); + const result = spawnSync( + process.execPath, + [ + scriptPath, + '--channel', + 'preview', + '--allow-stable', + 'false', + '--run-contracts', + 'false', + '--check-installers', + 'false', + ], + { + cwd: repoRoot, + env: { + ...process.env, + MINISIGN_SECRET_KEY: 'RWQpH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1vH1', + MINISIGN_PASSPHRASE: 'x', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.notEqual(result.status, 0, 'expected publish-hstack-binaries to fail for invalid minisign key'); + const stderr = String(result.stderr ?? ''); + assert.match(stderr, /MINISIGN_SECRET_KEY/i); + assert.match(stderr, /truncated|dotenv|multiline|file|path/i); + assert.doesNotMatch(String(result.stdout ?? ''), /build-hstack-binaries\.mjs/i, 'should fail before running the heavy build'); +}); + diff --git a/scripts/release/publish_run_contracts_auto_defaults.contract.test.mjs b/scripts/release/publish_run_contracts_auto_defaults.contract.test.mjs new file mode 100644 index 000000000..69a580b66 --- /dev/null +++ b/scripts/release/publish_run_contracts_auto_defaults.contract.test.mjs @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('publish-cli-binaries defaults to run-contracts=auto (skips locally)', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'release', 'publish-cli-binaries.mjs'), + '--channel', + 'preview', + '--allow-stable', + 'false', + '--check-installers', + 'false', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env, GITHUB_ACTIONS: '' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.doesNotMatch(out, /test:release:contracts/, 'local default should not run release contract tests'); +}); + +test('publish-cli-binaries defaults to run-contracts=auto (runs on GitHub Actions)', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'release', 'publish-cli-binaries.mjs'), + '--channel', + 'preview', + '--allow-stable', + 'false', + '--check-installers', + 'false', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env, GITHUB_ACTIONS: 'true' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.match(out, /test:release:contracts/, 'GitHub Actions default should run release contract tests'); +}); + +test('publish-ui-web defaults to run-contracts=auto (skips locally)', async () => { + const out = execFileSync( + process.execPath, + [ + resolve(repoRoot, 'scripts', 'pipeline', 'release', 'publish-ui-web.mjs'), + '--channel', + 'preview', + '--allow-stable', + 'false', + '--check-installers', + 'false', + '--dry-run', + ], + { + cwd: repoRoot, + env: { ...process.env, GITHUB_ACTIONS: '' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }, + ); + + assert.doesNotMatch(out, /test:release:contracts/, 'local default should not run release contract tests'); +}); + diff --git a/scripts/release/npm-e2e-smoke/Dockerfile b/scripts/release/release-assets-e2e/Dockerfile similarity index 100% rename from scripts/release/npm-e2e-smoke/Dockerfile rename to scripts/release/release-assets-e2e/Dockerfile diff --git a/scripts/release/npm-e2e-smoke/Dockerfile.remote-host b/scripts/release/release-assets-e2e/Dockerfile.remote-host similarity index 100% rename from scripts/release/npm-e2e-smoke/Dockerfile.remote-host rename to scripts/release/release-assets-e2e/Dockerfile.remote-host diff --git a/scripts/release/npm-e2e-smoke/Dockerfile.remote-host-systemd b/scripts/release/release-assets-e2e/Dockerfile.remote-host-systemd similarity index 100% rename from scripts/release/npm-e2e-smoke/Dockerfile.remote-host-systemd rename to scripts/release/release-assets-e2e/Dockerfile.remote-host-systemd diff --git a/scripts/release/npm-e2e-smoke/README.md b/scripts/release/release-assets-e2e/README.md similarity index 83% rename from scripts/release/npm-e2e-smoke/README.md rename to scripts/release/release-assets-e2e/README.md index e578ca0bf..3d951cc99 100644 --- a/scripts/release/npm-e2e-smoke/README.md +++ b/scripts/release/release-assets-e2e/README.md @@ -1,8 +1,9 @@ -# NPM E2E smoke (Docker) +# Release assets E2E (Docker) Repeatable manual end-to-end smoke for validating: - `@happier-dev/stack` (`hstack`) can self-host + start a server - `@happier-dev/cli` (`happier`) can point at that server and pass `happier server test` +- published Docker Hub images (`relay-server` + `dev-box`) This is meant for release validation and can run against: - real NPM dist-tags/versions (default) @@ -13,13 +14,13 @@ This is meant for release validation and can run against: From repo root: ```bash -./scripts/release/npm-e2e-smoke/run.sh +./scripts/release/release-assets-e2e/run.sh ``` ## Local tarballs (pre-publish) ```bash -./scripts/release/npm-e2e-smoke/run.sh --mode=local +./scripts/release/release-assets-e2e/run.sh --mode=local ``` ## Options @@ -51,6 +52,12 @@ From repo root: - `--remote-auth-mode=reuse-cli|bootstrap` (default: `reuse-cli`) - `reuse-cli`: authenticates the remote daemon onto the exact same account as the already-authenticated `cli` smoke machine (uses its home volume). - `bootstrap`: remote daemon smoke creates a separate local approver identity specifically to approve the remote machine pairing. +- `--with-docker-images` / `--no-docker-images` (default: `off`) + - When enabled, also validates the published Docker Hub images: + - `happierdev/relay-server:<channel>` runs with SQLite by default and can be configured for Postgres. + - `happierdev/dev-box:<channel>` runs `happier` in a “preinstalled” mode against the relay server. +- `--docker-channel=preview|stable` (default: `preview`) +- `--docker-images-db=sqlite|postgres|both` (default: `both`) ## What it does @@ -69,5 +76,5 @@ From repo root: If something fails, re-run with `--keep` and inspect logs: ```bash -docker compose -f ./scripts/release/npm-e2e-smoke/compose.yml logs -f stack +docker compose -f ./scripts/release/release-assets-e2e/compose.yml logs -f stack ``` diff --git a/scripts/release/npm-e2e-smoke/bin/cli-smoke.sh b/scripts/release/release-assets-e2e/bin/cli-smoke.sh similarity index 95% rename from scripts/release/npm-e2e-smoke/bin/cli-smoke.sh rename to scripts/release/release-assets-e2e/bin/cli-smoke.sh index 38d593e1a..1b44f3cfa 100755 --- a/scripts/release/npm-e2e-smoke/bin/cli-smoke.sh +++ b/scripts/release/release-assets-e2e/bin/cli-smoke.sh @@ -18,6 +18,13 @@ if [[ -n "$HAPPIER_TGZ" && -f "$HAPPIER_TGZ" ]]; then echo "[cli] installing happier-cli from tarball: $HAPPIER_TGZ" npm install -g "$HAPPIER_TGZ" >/dev/null HAPPIER_PREFIX=(happier) +elif [[ "$HAPPIER_CLI_INSTALL_MODE" == "preinstalled" ]]; then + echo "[cli] using preinstalled happier-cli" + if ! command -v happier >/dev/null 2>&1; then + echo "[cli] expected happier to be preinstalled (HAPPIER_CLI_INSTALL_MODE=preinstalled), but it was not found in PATH" >&2 + exit 1 + fi + HAPPIER_PREFIX=(happier) elif [[ "$HAPPIER_CLI_INSTALL_MODE" == "npx" ]]; then echo "[cli] running happier-cli via npx: $HAPPIER_NPM_SPEC" HAPPIER_PREFIX=(npx --yes -p "$HAPPIER_NPM_SPEC" happier) diff --git a/scripts/release/npm-e2e-smoke/bin/cli2-smoke.sh b/scripts/release/release-assets-e2e/bin/cli2-smoke.sh similarity index 95% rename from scripts/release/npm-e2e-smoke/bin/cli2-smoke.sh rename to scripts/release/release-assets-e2e/bin/cli2-smoke.sh index 0cb4cc4a3..2e3f526ba 100644 --- a/scripts/release/npm-e2e-smoke/bin/cli2-smoke.sh +++ b/scripts/release/release-assets-e2e/bin/cli2-smoke.sh @@ -18,6 +18,13 @@ if [[ -n "$HAPPIER_TGZ" && -f "$HAPPIER_TGZ" ]]; then echo "[cli2] installing happier-cli from tarball: $HAPPIER_TGZ" npm install -g "$HAPPIER_TGZ" >/dev/null HAPPIER_PREFIX=(happier) +elif [[ "$HAPPIER_CLI_INSTALL_MODE" == "preinstalled" ]]; then + echo "[cli2] using preinstalled happier-cli" + if ! command -v happier >/dev/null 2>&1; then + echo "[cli2] expected happier to be preinstalled (HAPPIER_CLI_INSTALL_MODE=preinstalled), but it was not found in PATH" >&2 + exit 1 + fi + HAPPIER_PREFIX=(happier) elif [[ "$HAPPIER_CLI_INSTALL_MODE" == "npx" ]]; then echo "[cli2] running happier-cli via npx: $HAPPIER_NPM_SPEC" HAPPIER_PREFIX=(npx --yes -p "$HAPPIER_NPM_SPEC" happier) diff --git a/scripts/release/npm-e2e-smoke/bin/remote-daemon-authenticated-cli-smoke.sh b/scripts/release/release-assets-e2e/bin/remote-daemon-authenticated-cli-smoke.sh similarity index 100% rename from scripts/release/npm-e2e-smoke/bin/remote-daemon-authenticated-cli-smoke.sh rename to scripts/release/release-assets-e2e/bin/remote-daemon-authenticated-cli-smoke.sh diff --git a/scripts/release/npm-e2e-smoke/bin/remote-daemon-smoke.sh b/scripts/release/release-assets-e2e/bin/remote-daemon-smoke.sh similarity index 100% rename from scripts/release/npm-e2e-smoke/bin/remote-daemon-smoke.sh rename to scripts/release/release-assets-e2e/bin/remote-daemon-smoke.sh diff --git a/scripts/release/npm-e2e-smoke/bin/remote-host-entrypoint.sh b/scripts/release/release-assets-e2e/bin/remote-host-entrypoint.sh similarity index 100% rename from scripts/release/npm-e2e-smoke/bin/remote-host-entrypoint.sh rename to scripts/release/release-assets-e2e/bin/remote-host-entrypoint.sh diff --git a/scripts/release/npm-e2e-smoke/bin/remote-host-systemd-entrypoint.sh b/scripts/release/release-assets-e2e/bin/remote-host-systemd-entrypoint.sh similarity index 100% rename from scripts/release/npm-e2e-smoke/bin/remote-host-systemd-entrypoint.sh rename to scripts/release/release-assets-e2e/bin/remote-host-systemd-entrypoint.sh diff --git a/scripts/release/npm-e2e-smoke/bin/remote-server-smoke.sh b/scripts/release/release-assets-e2e/bin/remote-server-smoke.sh similarity index 100% rename from scripts/release/npm-e2e-smoke/bin/remote-server-smoke.sh rename to scripts/release/release-assets-e2e/bin/remote-server-smoke.sh diff --git a/scripts/release/npm-e2e-smoke/bin/stack-entrypoint.sh b/scripts/release/release-assets-e2e/bin/stack-entrypoint.sh similarity index 100% rename from scripts/release/npm-e2e-smoke/bin/stack-entrypoint.sh rename to scripts/release/release-assets-e2e/bin/stack-entrypoint.sh diff --git a/scripts/release/npm-e2e-smoke/bin/terminal-auth-approve.cjs b/scripts/release/release-assets-e2e/bin/terminal-auth-approve.cjs similarity index 100% rename from scripts/release/npm-e2e-smoke/bin/terminal-auth-approve.cjs rename to scripts/release/release-assets-e2e/bin/terminal-auth-approve.cjs diff --git a/scripts/release/release-assets-e2e/compose.dockerhub.yml b/scripts/release/release-assets-e2e/compose.dockerhub.yml new file mode 100644 index 000000000..48f8d9fb4 --- /dev/null +++ b/scripts/release/release-assets-e2e/compose.dockerhub.yml @@ -0,0 +1,44 @@ +services: + relay: + image: ${HAPPIER_RELAY_IMAGE:?Missing HAPPIER_RELAY_IMAGE} + environment: + PORT: ${RELAY_PORT:-3005} + HAPPIER_DB_PROVIDER: ${RELAY_DB_PROVIDER:-sqlite} + HAPPY_DB_PROVIDER: ${RELAY_DB_PROVIDER:-sqlite} + DATABASE_URL: ${RELAY_DATABASE_URL:-} + RUN_MIGRATIONS: ${RELAY_RUN_MIGRATIONS:-1} + volumes: + - relay-data:/data + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-happier} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-happier} + POSTGRES_DB: ${POSTGRES_DB:-happier_smoke} + volumes: + - relay-postgres:/var/lib/postgresql/data + + devbox-smoke: + image: ${HAPPIER_DEVBOX_IMAGE:?Missing HAPPIER_DEVBOX_IMAGE} + depends_on: + - relay + environment: + HAPPIER_NPM_SPEC: ${HAPPIER_NPM_SPEC:-@happier-dev/cli@next} + HAPPIER_SERVER_URL: ${HAPPIER_SERVER_URL:-http://relay:3005} + HAPPIER_E2E_WITH_DAEMON: ${HAPPIER_E2E_WITH_DAEMON:-1} + HAPPIER_CLI_INSTALL_MODE: preinstalled + HAPPIER_ACTIVE_SERVER_ID: ${HAPPIER_ACTIVE_SERVER_ID:-smoke} + CLIENT_HOME_DIR: ${CLIENT_HOME_DIR:-/home/happier/happier-home} + APPROVER_HOME_DIR: ${APPROVER_HOME_DIR:-/home/happier/happier-approver-home} + volumes: + - ${REPO_ROOT:?Missing REPO_ROOT}:/repo:ro + - ${REPO_ROOT:?Missing REPO_ROOT}/scripts/release/release-assets-e2e/bin:/opt/happier-npm-e2e/bin:ro + working_dir: /workspace + command: ["bash", "/opt/happier-npm-e2e/bin/cli-smoke.sh"] + +volumes: + relay-data: + driver: local + relay-postgres: + driver: local diff --git a/scripts/release/npm-e2e-smoke/compose.local-monorepo.yml b/scripts/release/release-assets-e2e/compose.local-monorepo.yml similarity index 100% rename from scripts/release/npm-e2e-smoke/compose.local-monorepo.yml rename to scripts/release/release-assets-e2e/compose.local-monorepo.yml diff --git a/scripts/release/npm-e2e-smoke/compose.remote.yml b/scripts/release/release-assets-e2e/compose.remote.yml similarity index 100% rename from scripts/release/npm-e2e-smoke/compose.remote.yml rename to scripts/release/release-assets-e2e/compose.remote.yml diff --git a/scripts/release/npm-e2e-smoke/compose.yml b/scripts/release/release-assets-e2e/compose.yml similarity index 100% rename from scripts/release/npm-e2e-smoke/compose.yml rename to scripts/release/release-assets-e2e/compose.yml diff --git a/scripts/release/npm-e2e-smoke/run.sh b/scripts/release/release-assets-e2e/run.sh similarity index 68% rename from scripts/release/npm-e2e-smoke/run.sh rename to scripts/release/release-assets-e2e/run.sh index da22f0b5d..fc9c206b5 100755 --- a/scripts/release/npm-e2e-smoke/run.sh +++ b/scripts/release/release-assets-e2e/run.sh @@ -16,11 +16,14 @@ with_remote_server="" remote_installer="" remote_auth_mode="" remote_server_db="" +with_docker_images="" +docker_channel="" +docker_images_db="" show_help="0" usage() { cat <<EOF -Usage: $0 [--mode=npm|local] [--stack-spec <spec>] [--cli-spec <spec>] [--cli-install=global|npx] [--monorepo=github|local] [--timeout-s <seconds>] [--with-remote-daemon|--no-remote-daemon] [--with-remote-server|--no-remote-server] [--remote-server-db=postgres|sqlite] [--remote-installer=shim|official] [--remote-auth-mode=reuse-cli|bootstrap] [--keep] +Usage: $0 [--mode=npm|local] [--stack-spec <spec>] [--cli-spec <spec>] [--cli-install=global|npx] [--monorepo=github|local] [--timeout-s <seconds>] [--with-remote-daemon|--no-remote-daemon] [--with-remote-server|--no-remote-server] [--remote-server-db=postgres|sqlite] [--remote-installer=shim|official] [--remote-auth-mode=reuse-cli|bootstrap] [--with-docker-images|--no-docker-images] [--docker-channel=preview|stable] [--docker-images-db=sqlite|postgres|both] [--keep] Examples: $0 @@ -30,6 +33,7 @@ Examples: $0 --monorepo=local $0 --mode=local --with-remote-daemon --with-remote-server --remote-server-db=postgres --remote-installer=shim --remote-auth-mode=reuse-cli $0 --mode=npm --with-remote-daemon --remote-installer=official --remote-auth-mode=bootstrap + $0 --mode=npm --with-docker-images --docker-channel=preview --docker-images-db=both EOF } @@ -48,6 +52,10 @@ for arg in "$@"; do --remote-installer=*) remote_installer="${arg#*=}" ;; --remote-auth-mode=*) remote_auth_mode="${arg#*=}" ;; --remote-server-db=*) remote_server_db="${arg#*=}" ;; + --with-docker-images) with_docker_images="1" ;; + --no-docker-images) with_docker_images="0" ;; + --docker-channel=*) docker_channel="${arg#*=}" ;; + --docker-images-db=*) docker_images_db="${arg#*=}" ;; --keep) keep="1" ;; -h|--help) show_help="1" ;; *) echo "Unknown arg: $arg" >&2; usage; exit 2 ;; @@ -119,6 +127,30 @@ if [[ "$remote_auth_mode" != "reuse-cli" && "$remote_auth_mode" != "bootstrap" ] exit 2 fi +if [[ -z "$with_docker_images" ]]; then + with_docker_images="0" +fi +if [[ "$with_docker_images" != "0" && "$with_docker_images" != "1" ]]; then + echo "Invalid docker images mode (expected --with-docker-images or --no-docker-images)" >&2 + exit 2 +fi + +if [[ -z "$docker_channel" ]]; then + docker_channel="preview" +fi +if [[ "$docker_channel" != "preview" && "$docker_channel" != "stable" ]]; then + echo "Invalid --docker-channel=$docker_channel (expected preview|stable)" >&2 + exit 2 +fi + +if [[ -z "$docker_images_db" ]]; then + docker_images_db="both" +fi +if [[ "$docker_images_db" != "sqlite" && "$docker_images_db" != "postgres" && "$docker_images_db" != "both" ]]; then + echo "Invalid --docker-images-db=$docker_images_db (expected sqlite|postgres|both)" >&2 + exit 2 +fi + if [[ "$show_help" == "1" ]]; then usage exit 0 @@ -275,6 +307,167 @@ if [[ "$mode" == "local" ]]; then } >> "$env_file" fi +run_dockerhub_images_smoke() { + if [[ "$with_docker_images" != "1" ]]; then + return 0 + fi + + # Published Docker Hub images (see scripts/pipeline/docker/publish-images.mjs). + relay_image="happierdev/relay-server:${docker_channel}" + devbox_image="happierdev/dev-box:${docker_channel}" + + echo "[npm-e2e-smoke] checking dockerhub image availability..." + if ! docker manifest inspect "$relay_image" >/dev/null 2>&1; then + echo "[npm-e2e-smoke] missing relay-server image on dockerhub: $relay_image" >&2 + echo "[npm-e2e-smoke] hint: ensure the image is published, or run: docker login" >&2 + return 1 + fi + if ! docker manifest inspect "$devbox_image" >/dev/null 2>&1; then + echo "[npm-e2e-smoke] missing dev-box image on dockerhub: $devbox_image" >&2 + echo "[npm-e2e-smoke] hint: ensure the image is published, or run: docker login" >&2 + return 1 + fi + + db_cases=() + if [[ "$docker_images_db" == "both" ]]; then + db_cases=(sqlite postgres) + else + db_cases=("$docker_images_db") + fi + + for db_case in "${db_cases[@]}"; do + ( + set -euo pipefail + + images_project_name="${project_name}-dockerhub-${docker_channel}-${db_case}" + compose_images=(docker compose --project-name "$images_project_name" -f "$here/compose.dockerhub.yml") + + docker_env_file="$repo_root/output/npm-e2e-smoke.dockerhub.${docker_channel}.${db_case}.env" + rm -f "$docker_env_file" >/dev/null 2>&1 || true + + postgres_app_name="happier_npm_e2e_smoke_dockerhub_${docker_channel}_${db_case}" + database_url="postgresql://${POSTGRES_USER:-happier}:${POSTGRES_PASSWORD:-happier}@postgres:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-happier_smoke}?application_name=${postgres_app_name}" + + { + echo "REPO_ROOT=$repo_root" + echo "HAPPIER_RELAY_IMAGE=$relay_image" + echo "HAPPIER_DEVBOX_IMAGE=$devbox_image" + echo "HAPPIER_NPM_SPEC=$cli_spec" + echo "HAPPIER_SERVER_URL=http://relay:3005" + echo "HAPPIER_ACTIVE_SERVER_ID=smoke_dockerhub_${docker_channel}_${db_case}" + echo "CLIENT_HOME_DIR=/home/happier/happier-home" + echo "APPROVER_HOME_DIR=/home/happier/happier-approver-home" + + echo "POSTGRES_HOST=postgres" + echo "POSTGRES_PORT=5432" + echo "POSTGRES_USER=happier" + echo "POSTGRES_PASSWORD=happier" + echo "POSTGRES_DB=happier_smoke" + echo "POSTGRES_APP_NAME=$postgres_app_name" + + echo "RELAY_PORT=3005" + if [[ "$db_case" == "postgres" ]]; then + echo "RELAY_DB_PROVIDER=postgres" + echo "RELAY_DATABASE_URL=$database_url" + else + echo "RELAY_DB_PROVIDER=sqlite" + echo "RELAY_DATABASE_URL=" + fi + } >"$docker_env_file" + + cleanup_images() { + if [[ "$keep" == "1" ]]; then + echo "[npm-e2e-smoke] keeping dockerhub containers/volumes (use: ${compose_images[*]} --env-file $docker_env_file down -v)" >&2 + return 0 + fi + set +e + "${compose_images[@]}" --env-file "$docker_env_file" down -v >/dev/null 2>&1 || true + set -e + } + trap cleanup_images EXIT + + echo "[npm-e2e-smoke] starting dockerhub relay-server ($db_case)..." + if [[ "$db_case" == "postgres" ]]; then + echo "[npm-e2e-smoke] starting dockerhub postgres..." + "${compose_images[@]}" --env-file "$docker_env_file" up -d --force-recreate --renew-anon-volumes --remove-orphans postgres >/dev/null + + echo "[npm-e2e-smoke] waiting for dockerhub postgres..." + for _ in $(seq 1 90); do + if "${compose_images[@]}" --env-file "$docker_env_file" exec -T postgres sh -lc "pg_isready -U \"${POSTGRES_USER:-happier}\" -d \"${POSTGRES_DB:-happier_smoke}\" -h 127.0.0.1 -p 5432 >/dev/null 2>&1"; then + break + fi + sleep 1 + done + if ! "${compose_images[@]}" --env-file "$docker_env_file" exec -T postgres sh -lc "pg_isready -U \"${POSTGRES_USER:-happier}\" -d \"${POSTGRES_DB:-happier_smoke}\" -h 127.0.0.1 -p 5432 >/dev/null 2>&1"; then + echo "[npm-e2e-smoke] dockerhub postgres did not become ready" >&2 + "${compose_images[@]}" --env-file "$docker_env_file" logs --no-color postgres >&2 || true + exit 1 + fi + fi + + "${compose_images[@]}" --env-file "$docker_env_file" up -d --no-deps --force-recreate --renew-anon-volumes --remove-orphans relay >/dev/null + + echo "[npm-e2e-smoke] waiting for dockerhub relay-server..." + start_ts="$(date +%s)" + while true; do + if "${compose_images[@]}" exec -T relay bash -lc 'curl -fsS http://127.0.0.1:3005/v1/version >/dev/null && curl -fsS http://127.0.0.1:3005/ | head -c 4096 | grep -qi "<html"' >/dev/null 2>&1; then + break + fi + now_ts="$(date +%s)" + if (( now_ts - start_ts > 180 )); then + echo "[npm-e2e-smoke] dockerhub relay-server did not become ready (db=$db_case)" >&2 + "${compose_images[@]}" --env-file "$docker_env_file" logs --no-color relay >&2 || true + exit 1 + fi + sleep 2 + done + + echo "[npm-e2e-smoke] checking dockerhub relay-server env (db=$db_case)..." + expected_db="$db_case" + if [[ "$db_case" == "postgres" ]]; then + expected_db="postgres" + else + expected_db="sqlite" + fi + "${compose_images[@]}" exec -T relay bash -lc "test \"\${HAPPIER_DB_PROVIDER:-}\" = \"$expected_db\" || test \"\${HAPPY_DB_PROVIDER:-}\" = \"$expected_db\"" >/dev/null + + echo "[npm-e2e-smoke] running dockerhub dev-box happier-cli smoke (db=$db_case)..." + set +e + "${compose_images[@]}" --env-file "$docker_env_file" run --rm --no-deps devbox-smoke + status=$? + set -e + + if [[ $status -ne 0 ]]; then + echo "[npm-e2e-smoke] dev-box smoke failed (exit $status) (db=$db_case)" >&2 + echo "[npm-e2e-smoke] relay logs:" >&2 + "${compose_images[@]}" --env-file "$docker_env_file" logs --no-color relay >&2 || true + if [[ "$db_case" == "postgres" ]]; then + echo "[npm-e2e-smoke] postgres logs:" >&2 + "${compose_images[@]}" --env-file "$docker_env_file" logs --no-color postgres >&2 || true + fi + exit $status + fi + + if [[ "$db_case" == "postgres" ]]; then + echo "[npm-e2e-smoke] validating dockerhub relay-server postgres connectivity..." + for _ in $(seq 1 60); do + conn_count="$("${compose_images[@]}" --env-file "$docker_env_file" exec -T postgres sh -lc "psql -U \"${POSTGRES_USER:-happier}\" -d \"${POSTGRES_DB:-happier_smoke}\" -tAc \"select count(*) from pg_stat_activity where datname='${POSTGRES_DB:-happier_smoke}' and application_name='${postgres_app_name}';\" 2>/dev/null | tr -d '[:space:]' | head -n 1" || true)" + if [[ "$conn_count" =~ ^[0-9]+$ ]] && [[ "$conn_count" -ge 1 ]]; then + break + fi + sleep 1 + done + if ! [[ "${conn_count:-}" =~ ^[0-9]+$ ]] || [[ "$conn_count" -lt 1 ]]; then + echo "[npm-e2e-smoke] expected at least one postgres connection from relay-server (application_name=$postgres_app_name, got: ${conn_count:-missing})" >&2 + exit 1 + fi + fi + + echo "[npm-e2e-smoke] dockerhub images OK (db=$db_case)" + ) + done +} + if [[ "$monorepo_mode" == "local" ]]; then echo "[npm-e2e-smoke] using local monorepo as clone source..." echo "[npm-e2e-smoke] preparing a self-contained git clone for docker mount (worktree-safe)..." @@ -493,4 +686,6 @@ if [[ $status -ne 0 ]]; then exit $status fi +run_dockerhub_images_smoke + echo "[npm-e2e-smoke] OK" diff --git a/scripts/release/npm-e2e-smoke/run_help.test.mjs b/scripts/release/release-assets-e2e/run_help.test.mjs similarity index 76% rename from scripts/release/npm-e2e-smoke/run_help.test.mjs rename to scripts/release/release-assets-e2e/run_help.test.mjs index 5016fb2f1..2b15f3b70 100644 --- a/scripts/release/npm-e2e-smoke/run_help.test.mjs +++ b/scripts/release/release-assets-e2e/run_help.test.mjs @@ -127,3 +127,45 @@ test('npm-e2e-smoke postgres validation asserts connectivity (pg_stat_activity) const remoteServerSmoke = fs.readFileSync(join(here, 'bin', 'remote-server-smoke.sh'), 'utf8'); assert.match(remoteServerSmoke, /application_name=/); }); + +test('npm-e2e-smoke run.sh documents docker image smoke flags', () => { + const res = spawnSync('bash', [runScript, '--help'], { encoding: 'utf8' }); + assert.equal(res.status, 0); + assert.match(res.stdout ?? '', /--with-docker-images/); + assert.match(res.stdout ?? '', /--no-docker-images/); + assert.match(res.stdout ?? '', /--docker-channel=/); + assert.match(res.stdout ?? '', /--docker-images-db=/); +}); + +test('npm-e2e-smoke includes dockerhub compose and references published images', () => { + const composePath = join(here, 'compose.dockerhub.yml'); + assert.ok(fs.existsSync(composePath)); + + const compose = fs.readFileSync(composePath, 'utf8'); + assert.match(compose, /\n relay:\n/); + assert.match(compose, /HAPPIER_RELAY_IMAGE/); + assert.match(compose, /HAPPIER_DEVBOX_IMAGE/); + + // Ensure the runner defaults target the published dockerhub repositories. + const content = fs.readFileSync(runScript, 'utf8'); + assert.match(content, /happierdev\/relay-server/); + assert.match(content, /happierdev\/dev-box/); +}); + +test('npm-e2e-smoke cli-smoke.sh supports preinstalled happier-cli mode', () => { + const cliSmoke = fs.readFileSync(join(here, 'bin', 'cli-smoke.sh'), 'utf8'); + assert.match(cliSmoke, /HAPPIER_CLI_INSTALL_MODE/); + assert.match(cliSmoke, /preinstalled/); + assert.match(cliSmoke, /command -v happier/); +}); + +test('npm-e2e-smoke dockerhub postgres smoke waits for postgres readiness', () => { + const content = fs.readFileSync(runScript, 'utf8'); + assert.match(content, /waiting for dockerhub postgres/i); + assert.match(content, /pg_isready/); +}); + +test('npm-e2e-smoke dockerhub images smoke preflights image availability', () => { + const content = fs.readFileSync(runScript, 'utf8'); + assert.match(content, /docker manifest inspect/); +}); diff --git a/scripts/release/release_actor_guard_action.contract.test.mjs b/scripts/release/release_actor_guard_action.contract.test.mjs new file mode 100644 index 000000000..6b0bbd7b7 --- /dev/null +++ b/scripts/release/release_actor_guard_action.contract.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); + +test('release-actor-guard action supports trusted actors and URL-encodes actor paths', async () => { + const actionPath = resolve(repoRoot, '.github', 'actions', 'release-actor-guard', 'action.yml'); + const raw = fs.readFileSync(actionPath, 'utf8'); + + assert.match(raw, /\n\s*trusted_actors:\n/, 'action.yml must define a trusted_actors input'); + assert.match(raw, /INPUT_TRUSTED_ACTORS/, 'action should pass trusted_actors into the verify step env'); + assert.match(raw, /\|@uri/, 'action should URL-encode actor when building GitHub API URLs'); +}); + +test('deploy workflows trust the release bot actor for push-triggered deployments', async () => { + const deployOnPath = resolve(repoRoot, '.github', 'workflows', 'deploy-on-deploy-branch.yml'); + const deployPath = resolve(repoRoot, '.github', 'workflows', 'deploy.yml'); + + const deployOnRaw = fs.readFileSync(deployOnPath, 'utf8'); + const deployRaw = fs.readFileSync(deployPath, 'utf8'); + + assert.match( + deployOnRaw, + /trusted_actors:\s*happier-release-bot\[bot\]/, + 'deploy-on-deploy-branch should trust the release bot actor so deploy-branch pushes can deploy', + ); + assert.match( + deployRaw, + /trusted_actors:\s*happier-release-bot\[bot\]/, + 'deploy workflow should trust the release bot actor so workflow_call can deploy', + ); +}); diff --git a/scripts/release/release_local_orchestrator_logic.contract.test.mjs b/scripts/release/release_local_orchestrator_logic.contract.test.mjs new file mode 100644 index 000000000..31656b795 --- /dev/null +++ b/scripts/release/release_local_orchestrator_logic.contract.test.mjs @@ -0,0 +1,193 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { computeReleaseExecutionPlan } from '../../scripts/pipeline/release/lib/release-orchestrator.mjs'; + +test('preview: ui target runs publish_ui_web but does not deploy web by default', () => { + const plan = computeReleaseExecutionPlan({ + environment: 'preview', + dryRun: false, + forceDeploy: false, + deployTargets: ['ui', 'server', 'website', 'docs'], + uiExpoAction: 'none', + desktopMode: 'none', + changed: { + changed_ui: false, + changed_cli: false, + changed_server: false, + changed_website: false, + changed_docs: false, + changed_shared: false, + changed_stack: false, + }, + bumpPlan: { + bump_app: 'none', + bump_cli: 'none', + bump_stack: 'none', + bump_server: 'none', + bump_website: 'none', + should_bump: false, + publish_cli: false, + publish_stack: false, + publish_server: false, + }, + deployPlan: null, + }); + + assert.equal(plan.runPublishUiWeb, true); + assert.equal(plan.runDeployUi, false); +}); + +test('preview: changed cli publishes docker dev-box but not relay', () => { + const plan = computeReleaseExecutionPlan({ + environment: 'preview', + dryRun: false, + forceDeploy: false, + deployTargets: ['cli'], + uiExpoAction: 'none', + desktopMode: 'none', + changed: { + changed_ui: false, + changed_cli: true, + changed_server: false, + changed_website: false, + changed_docs: false, + changed_shared: false, + changed_stack: false, + }, + bumpPlan: { + bump_app: 'none', + bump_cli: 'none', + bump_stack: 'none', + bump_server: 'none', + bump_website: 'none', + should_bump: false, + publish_cli: true, + publish_stack: false, + publish_server: false, + }, + deployPlan: null, + }); + + assert.equal(plan.runPublishDocker, true); + assert.equal(plan.dockerBuildDevBox, true); + assert.equal(plan.dockerBuildRelay, false); +}); + +test('production: ui deploy runs when deploy plan says needed', () => { + const plan = computeReleaseExecutionPlan({ + environment: 'production', + dryRun: false, + forceDeploy: false, + deployTargets: ['ui'], + uiExpoAction: 'none', + desktopMode: 'none', + changed: { + changed_ui: true, + changed_cli: false, + changed_server: false, + changed_website: false, + changed_docs: false, + changed_shared: false, + changed_stack: false, + }, + bumpPlan: { + bump_app: 'none', + bump_cli: 'none', + bump_stack: 'none', + bump_server: 'none', + bump_website: 'none', + should_bump: false, + publish_cli: false, + publish_stack: false, + publish_server: false, + }, + deployPlan: { + deploy_ui: { needed: true }, + deploy_server: { needed: false }, + deploy_website: { needed: false }, + deploy_docs: { needed: false }, + }, + }); + + assert.equal(plan.runPromoteMain, true); + assert.equal(plan.runDeployUi, true); + assert.equal(plan.runPublishUiWeb, false); +}); + +test('preview: server_runner triggers publish_server_runtime', () => { + const plan = computeReleaseExecutionPlan({ + environment: 'preview', + dryRun: false, + forceDeploy: false, + deployTargets: ['server_runner'], + uiExpoAction: 'none', + desktopMode: 'none', + changed: { + changed_ui: false, + changed_cli: false, + changed_server: false, + changed_website: false, + changed_docs: false, + changed_shared: false, + changed_stack: false, + }, + bumpPlan: { + bump_app: 'none', + bump_cli: 'none', + bump_stack: 'none', + bump_server: 'none', + bump_website: 'none', + should_bump: false, + publish_cli: false, + publish_stack: false, + publish_server: true, + }, + deployPlan: null, + }); + + assert.equal(plan.runPublishServerRuntime, true); +}); + +test('dry-run: no mutating jobs run', () => { + const plan = computeReleaseExecutionPlan({ + environment: 'production', + dryRun: true, + forceDeploy: true, + deployTargets: ['ui', 'server', 'website', 'docs', 'cli', 'stack', 'server_runner'], + uiExpoAction: 'native_submit', + desktopMode: 'build_and_publish', + changed: { + changed_ui: true, + changed_cli: true, + changed_server: true, + changed_website: true, + changed_docs: true, + changed_shared: true, + changed_stack: true, + }, + bumpPlan: { + bump_app: 'major', + bump_cli: 'major', + bump_stack: 'major', + bump_server: 'major', + bump_website: 'major', + should_bump: true, + publish_cli: true, + publish_stack: true, + publish_server: true, + }, + deployPlan: { + deploy_ui: { needed: true }, + deploy_server: { needed: true }, + deploy_website: { needed: true }, + deploy_docs: { needed: true }, + }, + }); + + for (const [k, v] of Object.entries(plan)) { + if (k.startsWith('docker')) continue; + assert.equal(v, false, `expected ${k} to be false in dry-run mode`); + } +}); + diff --git a/scripts/release/release_orchestrator_preview.contract.test.mjs b/scripts/release/release_orchestrator_preview.contract.test.mjs index 0ce5f9dce..ace2d8827 100644 --- a/scripts/release/release_orchestrator_preview.contract.test.mjs +++ b/scripts/release/release_orchestrator_preview.contract.test.mjs @@ -120,8 +120,8 @@ test('release-npm embeds build feature policy defaults by channel', async () => test('release-npm is compatible with npm trusted publishing (OIDC)', async () => { const raw = await loadWorkflow('release-npm.yml'); - assert.match(raw, /node scripts\/pipeline\/npm\/publish-tarball\.mjs/, 'release-npm should delegate npm publishing to the pipeline script'); - assert.match(raw, /node scripts\/pipeline\/npm\/release-packages\.mjs/, 'release-npm should delegate npm pack preparation to the pipeline script'); + assert.match(raw, /node scripts\/pipeline\/run\.mjs npm-publish/, 'release-npm should delegate npm publishing to the pipeline command'); + assert.match(raw, /node scripts\/pipeline\/run\.mjs npm-release/, 'release-npm should delegate npm pack preparation to the pipeline command'); assert.doesNotMatch(raw, /npm pack --ignore-scripts --json/, 'release-npm should not embed npm pack json parsing boilerplate (use release-packages.mjs)'); assert.doesNotMatch(raw, /npm install --global npm@11/, 'release-npm should avoid global npm installs (use pinned npm via npx inside the pipeline)'); assert.doesNotMatch(raw, /NPM_TOKEN is required for npm publish\./); @@ -159,7 +159,7 @@ test('release-npm derives unique preview prerelease versions from base versions' assert.doesNotMatch(raw, /version_bump_cli/); assert.doesNotMatch(raw, /version_bump_stack/); assert.doesNotMatch(raw, /function bumpBase\(base, bump\)/); - assert.match(raw, /node scripts\/pipeline\/npm\/set-preview-versions\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs npm-set-preview-versions/); assert.doesNotMatch(raw, /function setPreviewVersion\(pkgPath\)/); assert.doesNotMatch(raw, /\$\{base\}-preview\.\$\{run\}\.\$\{attempt\}/); assert.match(raw, /publish_server/, 'release-npm should expose publish_server for server runner publishing'); @@ -169,7 +169,7 @@ test('release-npm derives unique preview prerelease versions from base versions' assert.match(raw, /dir="packages\/relay-server"/); assert.match(raw, /SERVER_RUNNER_DIR:\s*\$\{\{ steps\.server_runner\.outputs\.dir \}\}/); assert.match(raw, /yarn --cwd [^\n]*steps\.server_runner\.outputs\.dir[^\n]* test/); - assert.match(raw, /node scripts\/pipeline\/npm\/release-packages\.mjs[\s\S]*?--server-runner-dir "\$\{SERVER_RUNNER_DIR\}"/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs npm-release[\s\S]*?--server-runner-dir "\$\{SERVER_RUNNER_DIR\}"/); const script = await loadFile('scripts/pipeline/npm/set-preview-versions.mjs'); assert.match(script, /GITHUB_RUN_NUMBER/); @@ -201,7 +201,7 @@ test('release-npm does not manage deploy/* branches (deploy is for server/web ap test('publish-github-release delegates release creation + asset upload to the pipeline script', async () => { const raw = await loadWorkflow('publish-github-release.yml'); - assert.match(raw, /node scripts\/pipeline\/github\/publish-release\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs github-publish-release/); assert.doesNotMatch(raw, /gh release upload/, 'publish-github-release should not embed gh release upload logic'); assert.doesNotMatch(raw, /gh api -X DELETE/, 'publish-github-release should not embed release asset pruning logic'); }); @@ -227,7 +227,7 @@ test('promote-ui native_submit uses the shared Expo submit script (handles previ test('promote-ui preview OTA updates are non-interactive and provide an update message', async () => { const raw = await loadWorkflow('promote-ui.yml'); assert.match(raw, /- name: Expo OTA update/); - assert.match(raw, /node scripts\/pipeline\/expo\/ota-update\.mjs/); + assert.match(raw, /node scripts\/pipeline\/run\.mjs expo-ota/); const script = await loadFile('scripts/pipeline/expo/ota-update.mjs'); assert.match(script, /eas-cli@\$\{easCliVersion\}/); diff --git a/scripts/release/rolling_release_notes.contract.test.mjs b/scripts/release/rolling_release_notes.contract.test.mjs new file mode 100644 index 000000000..d7b2d9c2c --- /dev/null +++ b/scripts/release/rolling_release_notes.contract.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { withCurrentVersionLine } from '../pipeline/release/lib/rolling-release-notes.mjs'; + +test('rolling release notes: appends current version line', () => { + const out = withCurrentVersionLine('Rolling preview CLI binaries.', '1.2.3-preview.1.2'); + assert.match(out, /Rolling preview CLI binaries\./); + assert.match(out, /Current version: v1\.2\.3-preview\.1\.2/); +}); + +test('rolling release notes: does not duplicate current version line', () => { + const out = withCurrentVersionLine('Rolling.\n\nCurrent version: v1.0.0', '1.0.0'); + assert.equal(out, 'Rolling.\n\nCurrent version: v1.0.0'); +}); + diff --git a/scripts/release/tauri_signing_key_file.test.mjs b/scripts/release/tauri_signing_key_file.test.mjs new file mode 100644 index 000000000..0928e6be9 --- /dev/null +++ b/scripts/release/tauri_signing_key_file.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { ensureTauriSigningKeyFile } from '../pipeline/tauri/ensure-signing-key-file.mjs'; + +test('ensureTauriSigningKeyFile materializes inline key contents to a temp file', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'happier-tauri-key-')); + const tmpRoot = path.join(dir, 'tmp'); + const key = 'untrusted comment: tauri signing key\\nRWabc123\\n'; + + const outPath = await ensureTauriSigningKeyFile({ tmpRoot, keyValue: key, dryRun: false }); + assert.ok(outPath.includes('tauri.signing.key'), 'expected a stable signing key filename'); + + const contents = await readFile(outPath, 'utf8'); + assert.equal(contents, 'untrusted comment: tauri signing key\nRWabc123\n'); +}); + +test('ensureTauriSigningKeyFile returns the path unchanged when keyValue is an existing file path', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'happier-tauri-key-path-')); + const keyPath = path.join(dir, 'key.txt'); + await writeFile(keyPath, 'hello\n', 'utf8'); + + const outPath = await ensureTauriSigningKeyFile({ tmpRoot: dir, keyValue: keyPath, dryRun: false }); + assert.equal(outPath, keyPath); +}); + +test('ensureTauriSigningKeyFile supports dry-run without writing', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'happier-tauri-key-dry-')); + const tmpRoot = path.join(dir, 'tmp'); + const outPath = await ensureTauriSigningKeyFile({ tmpRoot, keyValue: 'RWabc123', dryRun: true }); + assert.ok(outPath.includes('tauri.signing.key'), 'expected a stable signing key filename'); +}); + diff --git a/scripts/release/tests_workflow.daemon_e2e_lane.contract.test.mjs b/scripts/release/tests_workflow.daemon_e2e_lane.contract.test.mjs index a38020497..fcc87cffd 100644 --- a/scripts/release/tests_workflow.daemon_e2e_lane.contract.test.mjs +++ b/scripts/release/tests_workflow.daemon_e2e_lane.contract.test.mjs @@ -22,8 +22,8 @@ test('tests workflow creates daemon e2e credentials via pipeline script (no inli assert.match( raw, - /node scripts\/pipeline\/testing\/create-auth-credentials\.mjs/, - 'tests.yml should delegate /v1/auth credentials bootstrap to scripts/pipeline/testing/create-auth-credentials.mjs', + /node scripts\/pipeline\/run\.mjs testing-create-auth-credentials/, + 'tests.yml should delegate /v1/auth credentials bootstrap to the pipeline command (no direct leaf script call)', ); assert.doesNotMatch( diff --git a/scripts/release/workflow_pipeline_prereqs.contract.test.mjs b/scripts/release/workflow_pipeline_prereqs.contract.test.mjs new file mode 100644 index 000000000..92a6d1bc8 --- /dev/null +++ b/scripts/release/workflow_pipeline_prereqs.contract.test.mjs @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import YAML from 'yaml'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); +const workflowsDir = join(repoRoot, '.github', 'workflows'); + +/** + * @param {unknown} value + * @returns {value is Record<string, any>} + */ +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * @param {unknown} step + * @returns {step is { uses?: string; run?: string }} + */ +function isStepLike(step) { + if (!isRecord(step)) return false; + return typeof step.uses === 'string' || typeof step.run === 'string'; +} + +/** + * @param {unknown} workflow + * @returns {Record<string, any>} + */ +function parseWorkflow(workflow) { + if (!isRecord(workflow)) return {}; + return workflow; +} + +test('workflows running pipeline scripts check out code and set up Node first', async () => { + const files = (await readdir(workflowsDir)).filter((name) => name.endsWith('.yml')); + + for (const file of files) { + const raw = await readFile(join(workflowsDir, file), 'utf8'); + if (!raw.includes('node scripts/pipeline/')) continue; + + /** @type {any} */ + const parsed = YAML.parse(raw, { prettyErrors: true }); + const workflow = parseWorkflow(parsed); + const jobs = workflow.jobs; + if (!isRecord(jobs)) continue; + + for (const [jobId, job] of Object.entries(jobs)) { + if (!isRecord(job)) continue; + const steps = job.steps; + if (!Array.isArray(steps)) continue; + + const pipelineStepIndexes = steps + .map((step, idx) => { + if (!isStepLike(step)) return -1; + const run = typeof step.run === 'string' ? step.run : ''; + return run.includes('node scripts/pipeline/') ? idx : -1; + }) + .filter((idx) => idx >= 0); + + if (pipelineStepIndexes.length === 0) continue; + + const firstPipelineIndex = Math.min(...pipelineStepIndexes); + const prereqSteps = steps.slice(0, firstPipelineIndex).filter(isStepLike); + + const hasCheckout = prereqSteps.some((step) => typeof step.uses === 'string' && step.uses.includes('actions/checkout@v4')); + const hasSetupNode = prereqSteps.some((step) => typeof step.uses === 'string' && step.uses.includes('actions/setup-node@v4')); + + assert.ok( + hasCheckout, + `${file} job '${jobId}' runs pipeline scripts but does not run actions/checkout@v4 before the first pipeline step`, + ); + assert.ok( + hasSetupNode, + `${file} job '${jobId}' runs pipeline scripts but does not run actions/setup-node@v4 before the first pipeline step`, + ); + } + } +}); + diff --git a/scripts/release/workflows_node_script_paths.contract.test.mjs b/scripts/release/workflows_node_script_paths.contract.test.mjs new file mode 100644 index 000000000..cd7d52559 --- /dev/null +++ b/scripts/release/workflows_node_script_paths.contract.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); +const workflowsDir = path.join(repoRoot, '.github', 'workflows'); + +/** + * @param {string} p + */ +function normalizePath(p) { + return p.replaceAll('\\\\', '/'); +} + +test('workflows only reference existing node script entrypoints', () => { + const files = fs + .readdirSync(workflowsDir, { withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith('.yml')) + .map((e) => path.join(workflowsDir, e.name)); + + /** @type {{ workflow: string; script: string }[]} */ + const missing = []; + + for (const workflowPath of files) { + const raw = fs.readFileSync(workflowPath, 'utf8'); + const workflowName = path.relative(repoRoot, workflowPath) || workflowPath; + + const re = /\bnode\s+([./A-Za-z0-9_-]+\.mjs)\b/g; + for (const match of raw.matchAll(re)) { + const scriptRel = String(match[1] ?? '').trim(); + if (!scriptRel) continue; + if (path.isAbsolute(scriptRel)) continue; + + const abs = path.join(repoRoot, scriptRel); + if (!fs.existsSync(abs)) { + missing.push({ workflow: normalizePath(workflowName), script: normalizePath(scriptRel) }); + } + } + } + + assert.deepEqual( + missing, + [], + missing.length > 0 + ? `Missing node script(s) referenced by workflows:\n${missing.map((m) => `- ${m.workflow}: ${m.script}`).join('\n')}` + : 'expected no missing node script references', + ); +}); + diff --git a/skills/happier-github-ops/SKILL.md b/skills/happier-github-ops/SKILL.md new file mode 100644 index 000000000..37ad3f4d3 --- /dev/null +++ b/skills/happier-github-ops/SKILL.md @@ -0,0 +1,130 @@ +--- +name: happier-github-ops +description: Run GitHub CLI commands as the Happier bot account via `yarn ghops` (forced PAT auth + non-interactive). +--- + +# Happier GitHub Ops (bot `gh` wrapper) + +This repo provides `yarn ghops` as a thin wrapper around the GitHub CLI (`gh`) that **forces** authentication via the bot Personal Access Token. + +## Prerequisites + +- `gh` is installed on the host and reachable on `PATH`. +- Environment variable `HAPPIER_GITHUB_BOT_TOKEN` is set to the bot's fine-grained PAT. + +## Contract / Safety + +- `yarn ghops ...` refuses to run if `HAPPIER_GITHUB_BOT_TOKEN` is missing. +- Runs non-interactively (`GH_PROMPT_DISABLED=1`). +- Uses an isolated repo-local `GH_CONFIG_DIR` by default. + +## What to write (LLM guidelines) + +When creating/updating public issues, keep it **useful but minimal**: + +- Prefer **user impact, repro steps, expected vs actual**, and **acceptance criteria**. +- Link to PRs/commits by URL when available. +- Avoid internal-only detail: no private logs, no secrets, no tokens, and no stack dumps from private environments. +- If you need to share sensitive debugging context, summarize it and keep the raw detail local. + +Suggested comment format for progress updates: + +- What changed (1–3 bullets) +- Why (brief) +- Next step / what’s blocked (one line) +- Links (PR/commit/issues) + +## Common commands + +Verify identity (must be the bot user): + +```bash +yarn ghops api user +``` + +## Project conventions (Happier roadmap) + +Canonical public roadmap project: + +- Owner: `happier-dev` +- Project number: `1` +- URL: `https://github.com/orgs/happier-dev/projects/1` + +## Labels (conventions) + +These labels are intended to keep the public roadmap curated and consistent: + +- `roadmap` (triage-owned): include this item on the public roadmap project +- `priority:p0`, `priority:p1`, `priority:p2`, `priority:p3` (triage-owned) +- `stage:not-shipped`, `stage:experimental`, `stage:beta`, `stage:ga` (optional; rollout state) +- `type: bug`, `type: feature`, `type: task` (recommended) +- `source: bug-report` (applied automatically by the bug-report service) + +When asked to “create an issue and put it on the roadmap with P0”, do: + +1) Create the issue +2) Apply `roadmap` and `priority:p0` (and a `type:*` label) +3) Ensure it lands on the roadmap project (automation should add it; if not, add explicitly) + +When you create or meaningfully update an issue/PR, ensure it’s visible on the roadmap: + +- Prefer GitHub Project automation (auto-add when `roadmap` label is present). +- If you’re not sure it will be auto-added, explicitly add it: + +```bash +yarn ghops project item-add 1 --owner happier-dev --url https://github.com/happier-dev/happier/issues/123 +``` + +Create an issue (repo explicit is recommended): + +```bash +yarn ghops issue create -R happier-dev/happier --title "..." --body "..." --label "type: bug" +``` + +For CLI-created issues, format the body like the templates: + +- Bug: summary + what happened + expected behavior + (optional) repro + (optional) frequency/severity + (optional) environment +- Feature: problem + proposal + acceptance criteria + +For scripting / machine-readable output, prefer `gh api`: + +```bash +yarn ghops api repos/happier-dev/happier/issues \ + -f title="..." \ + -f body="..." \ + --jq '{number: .number, url: .html_url}' +``` + +Comment on an issue: + +```bash +yarn ghops api repos/happier-dev/happier/issues/123/comments -f body="Update: ..." +``` + +Apply labels (example): + +```bash +yarn ghops api repos/happier-dev/happier/issues/123/labels -f labels[]="roadmap" -f labels[]="priority:p0" +``` + +## Titles (guidelines) + +Prefer short, descriptive titles without noisy prefixes: + +- Good: `Sessions flicker online/inactive` +- Good: `CLI: doctor fails when daemon is stopped` +- Avoid: `P0: ...` (priority belongs in the project/labels, not the title) +- Avoid: long bracket stacks like `[Bug][iOS][P0] ...` + +Add an issue/PR to the org project (Project v2): + +```bash +yarn ghops project item-add 1 --owner happier-dev --url https://github.com/happier-dev/happier/issues/123 +``` + +List project fields/items (JSON): + +```bash +yarn ghops project field-list 1 --owner happier-dev --format json +yarn ghops project item-list 1 --owner happier-dev --format json +``` diff --git a/yarn.lock b/yarn.lock index fa8873db1..4fe292bc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -950,10 +950,10 @@ "@noble/curves" "1.5.0" "@noble/hashes" "1.4.0" -"@config-plugins/react-native-webrtc@^12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@config-plugins/react-native-webrtc/-/react-native-webrtc-12.0.0.tgz#2bad8d59fa8aeb1b311c82c8704259154b00805f" - integrity sha512-EIYR+ArIOFBz8cEHSdPjxqFPhaN+nNeoPnI6iVStctYKZdl6AEgmXq6Qfpds6qyin1W4JP7wq2jJh32OsRfwxg== +"@config-plugins/react-native-webrtc@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@config-plugins/react-native-webrtc/-/react-native-webrtc-13.0.0.tgz#8242d5afd89e269630918c448ac1503fd84fdc41" + integrity sha512-EtRRLXmsU4GcDA3TgIxtqg++eh/CjbI6EV8N/1EFQTtaWI2lpww0fg+S0wd+ndXE0dFWaLqUFvZuyTAaAoOSeA== "@cspotcode/source-map-support@^0.8.0": version "0.8.1" @@ -3346,6 +3346,13 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@playwright/test@^1.56.0": + version "1.58.2" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.58.2.tgz#b0ad585d2e950d690ef52424967a42f40c6d2cbd" + integrity sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA== + dependencies: + playwright "1.58.2" + "@posthog/core@1.14.1": version "1.14.1" resolved "https://registry.yarnpkg.com/@posthog/core/-/core-1.14.1.tgz#0796611d9f3438d499198d299a61268d19d491ff" @@ -9191,6 +9198,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -13122,6 +13134,20 @@ platform@^1.3.6: resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7" integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg== +playwright-core@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.58.2.tgz#ac5f5b4b10d29bcf934415f0b8d133b34b0dcb13" + integrity sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg== + +playwright@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.58.2.tgz#afe547164539b0bcfcb79957394a7a3fa8683cfd" + integrity sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A== + dependencies: + playwright-core "1.58.2" + optionalDependencies: + fsevents "2.3.2" + plist@^3.0.5: version "3.1.0" resolved "https://registry.yarnpkg.com/plist/-/plist-3.1.0.tgz#797a516a93e62f5bde55e0b9cc9c967f860893c9"